Compare commits

...
2 Commits
Author SHA1 Message Date
chenmingxuan 0e13bb5c29 fix: synchronize MCP clients on launch 2026-09-24 12:07:42 +08:00
chenmingxuan 8522fa3ca3 fix: ship updatable 1.1.1 source archives
Package a clean shallow main checkout with credential-free Gitea remote, localize the China launcher, and set the new-install Responses endpoint.
2026-09-24 10:49:57 +08:00
24 changed files with 904 additions and 248 deletions

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
# 所有模型调用仅使用 OpenAI Responses API;环境变量优先于 .env。 # 所有模型调用仅使用 OpenAI Responses API;环境变量优先于 .env。
OPENAI_API_KEY= OPENAI_API_KEY=
# 接口根地址:通常以 /v1 结尾,不要添加 /responses 或 /chat/completions。 # 接口根地址:通常以 /v1 结尾,不要添加 /responses 或 /chat/completions。
OPENAI_BASE_URL=https://api.openai.com/v1 OPENAI_BASE_URL=https://api-n-cd.playones.com/v1
OPENAI_MODEL=mimo-v2.6-flash OPENAI_MODEL=mimo-v2.6-flash
# 单次模型 HTTP 请求超时(秒) # 单次模型 HTTP 请求超时(秒)
OPENAI_TIMEOUT_SECONDS=300 OPENAI_TIMEOUT_SECONDS=300
+9 -8
View File
@@ -12,7 +12,7 @@
## 安装与启动 ## 安装与启动
准备一台开启 USB 调试并完成授权的 Android 手机,或已启动的 Android 模拟器。首次安装需要联网,建议使用 Git 克隆以支持后续更新。 准备一台开启 USB 调试并完成授权的 Android 手机,或已启动的 Android 模拟器。首次安装需要联网。可使用 Git 克隆或下载 1.1.1 及更新版本的发布源码包;新版 ZIP 解压后也保留 Git 更新能力。
### Windows ### Windows
@@ -24,14 +24,15 @@ cd Artemis-Eapil
.\start.bat .\start.bat
``` ```
国内网络使用 `.\start-cn.bat`。启动脚本会检测并安装缺少的 uv、Python 和所需工具,创建或复用 `.venv`,同步依赖并构建网页。启动后访问 **http://127.0.0.1:8000**。 国内网络使用 `.\start-cn.bat`,支持 `-Help` 查看中文选项。启动脚本会检测并安装缺少的 uv、Python 和所需工具,创建或复用 `.venv`,同步依赖并构建网页。启动后访问 **http://127.0.0.1:8000**。
常用选项: 常用选项:
```powershell ```powershell
.\start-cn.bat -SetupOnly # 仅准备 Python 环境并检查已有 MCP 配置 .\start-cn.bat -SetupOnly # 准备环境并安装/更新已检测客户端的 MCP
.\start.bat -Port 8001 # 指定端口 .\start.bat -Port 8001 # 指定端口
.\start.bat -SkipUpdate # 跳过 Git 源码更新检查 .\start.bat -SkipUpdate # 跳过 Git 源码更新检查
.\start.bat -SkipMcpSync # MCP 配置异常时,仅启动网页
``` ```
镜像与下载超时在 [config/downloads.json](config/downloads.json) 中设置。国内脚本使用山东大学镜像安装 scrcpy,并校验官方 SHA256;也可预先安装或自定义 HTTPS 下载地址。 镜像与下载超时在 [config/downloads.json](config/downloads.json) 中设置。国内脚本使用山东大学镜像安装 scrcpy,并校验官方 SHA256;也可预先安装或自定义 HTTPS 下载地址。
@@ -49,19 +50,19 @@ cd Artemis-Eapil
预设模型为 `gpt-5.6-luna`、`mimo-v2.6-flash`,也可填写自定义名称。默认模型是 `mimo-v2.6-flash`,思考强度默认 `high`(最高可选 `max`),单次请求超时 **300 秒**,失败后最多重试 **5 次**,间隔 **10 秒**;视频分析与录屏默认关闭。模型及思考参数是否可用取决于所配置网关;不支持时会明确报错。 预设模型为 `gpt-5.6-luna`、`mimo-v2.6-flash`,也可填写自定义名称。默认模型是 `mimo-v2.6-flash`,思考强度默认 `high`(最高可选 `max`),单次请求超时 **300 秒**,失败后最多重试 **5 次**,间隔 **10 秒**;视频分析与录屏默认关闭。模型及思考参数是否可用取决于所配置网关;不支持时会明确报错。
也可在项目 `.env` 中设置 `OPENAI_API_KEY`、`OPENAI_BASE_URL` 和 `OPENAI_MODEL`,其他参数见 [.env.example](.env.example)。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。 也可在项目 `.env` 中设置 `OPENAI_API_KEY`、`OPENAI_BASE_URL` 和 `OPENAI_MODEL`,其他参数见 [.env.example](.env.example)。新安装默认接口地址为 `https://api-n-cd.playones.com/v1`;已保存的地址不变。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。
报告会区分“执行完成”和“验证通过”;没有验证证据的结果标为“未验证”。任务步骤与截图可查看,设备动作回放不提供。 报告会区分“执行完成”和“验证通过”;没有验证证据的结果标为“未验证”。任务步骤与截图可查看,设备动作回放不提供。
## 让 AI 使用 ## 让 AI 使用
环境与模型配置完成后,例如为 Codex 接入 MCP: Windows 启动脚本会为已检测到的 AI 客户端安装或更新 MCP。未被检测到的客户端,可手动安装,例如 Codex:
```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` 查看接入方式。 安装或更新后,在客户端重新加载 MCP 或重启客户端,并按 [AI 接入指南](docs/ai-automation.md) 安装仓库中的 [artemis-automation Skill](skills/artemis-automation/SKILL.md)。Skill 不会随启动脚本自动安装到用户全局目录。其他客户端可通过 `uv run artemis mcp --help` 查看接入方式。
示例指令: 示例指令:
@@ -69,9 +70,9 @@ uv run artemis mcp --install codex
## 更新与排错 ## 更新与排错
Windows 用户再次运行一键脚本时,会先检查 Git 上游;即使 `start.bat` 或 `start-cn.bat` 本身被更新,也会重新启动新版脚本,再检查项目依赖、前端构建和已有 MCP 配置。源码只做快进更新。本地修改、分支分叉或网络认证失败时保留本地版本;有任务执行或排队时停止更新启动。更新后需在 AI 客户端重新加载 MCP。 Windows 用户再次运行一键脚本时,会先检查 Git 上游;即使 `start.bat` 或 `start-cn.bat` 本身被更新,也会重新启动新版脚本,再检查项目依赖、前端构建并同步已检测客户端的 MCP。源码只做快进更新。本地修改、分支分叉或网络认证失败时保留本地版本;有任务执行或排队时停止更新启动。MCP 配置同步失败会明确停止启动,可使用 `-SkipMcpSync` 仅启动网页。更新后需在 AI 客户端重新加载 MCP。
ZIP 安装可继续运行;需要自动拉取源码时,请改用 Git 克隆安装。遇到启动或设备问题,在项目目录运行 `uv run artemis doctor`。 1.1.1 及更新版本的发布 ZIP 含浅层 Git 工作树,解压后再次运行一键脚本即可检查更新。1.1.0 及更早 ZIP 不含 Git 信息,无法自行拉取;需要下载新版源码包重新解压一次,之后即可自动更新。遇到启动或设备问题,在项目目录运行 `uv run artemis doctor`。
更多说明:[批量任务与报告](docs/automation-workspace.md) · [AI 接入](docs/ai-automation.md) · [开发协作指南](AGENTS.md) 更多说明:[批量任务与报告](docs/automation-workspace.md) · [AI 接入](docs/ai-automation.md) · [开发协作指南](AGENTS.md)
@@ -16,7 +16,7 @@ _write_lock = threading.Lock()
class ModelSettingsUpdate(BaseModel): class ModelSettingsUpdate(BaseModel):
base_url: str = "https://api.openai.com/v1" base_url: str = "https://api-n-cd.playones.com/v1"
model: str = Field(min_length=1, max_length=200) model: str = Field(min_length=1, max_length=200)
api_key: SecretStr | None = None # Omitted/blank keeps the existing key. api_key: SecretStr | None = None # Omitted/blank keeps the existing key.
timeout_seconds: float = Field(default=300, gt=0, le=3600) timeout_seconds: float = Field(default=300, gt=0, le=3600)
@@ -41,7 +41,7 @@ class ModelSettingsUpdate(BaseModel):
): ):
raise ValueError("请输入有效的 HTTP(S) 接口根地址,不要包含密钥、查询参数或片段") raise ValueError("请输入有效的 HTTP(S) 接口根地址,不要包含密钥、查询参数或片段")
if value.endswith(("/responses", "/chat/completions")): if value.endswith(("/responses", "/chat/completions")):
raise ValueError("请输入接口根地址,例如 https://api.openai.com/v1") raise ValueError("请输入接口根地址,例如 https://api-n-cd.playones.com/v1")
return value return value
@field_validator("model") @field_validator("model")
@@ -57,7 +57,7 @@ def get_model_settings() -> dict:
return { return {
"provider": "openai", "provider": "openai",
"api_mode": "responses", "api_mode": "responses",
"base_url": settings.OPENAI_BASE_URL or "https://api.openai.com/v1", "base_url": settings.OPENAI_BASE_URL or "https://api-n-cd.playones.com/v1",
"model": settings.OPENAI_MODEL, "model": settings.OPENAI_MODEL,
"api_key_configured": bool(settings.OPENAI_API_KEY), "api_key_configured": bool(settings.OPENAI_API_KEY),
"timeout_seconds": settings.OPENAI_TIMEOUT_SECONDS, "timeout_seconds": settings.OPENAI_TIMEOUT_SECONDS,
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "1.1.0", "version": "1.1.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "1.1.0", "version": "1.1.2",
"dependencies": { "dependencies": {
"@angular/cdk": "^22.1.5", "@angular/cdk": "^22.1.5",
"@angular/common": "^22.1.4", "@angular/common": "^22.1.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "1.1.0", "version": "1.1.2",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json", "start": "ng serve --proxy-config proxy.conf.json",
@@ -24,7 +24,7 @@ interface ModelSettings {
<p>统一使用 OpenAI Responses。保存后新任务立即生效,重启后自动加载。</p> <p>统一使用 OpenAI Responses。保存后新任务立即生效,重启后自动加载。</p>
<label for="model-base-url">接口地址</label> <label for="model-base-url">接口地址</label>
<input id="model-base-url" name="baseUrl" type="url" [(ngModel)]="baseUrl" required <input id="model-base-url" name="baseUrl" type="url" [(ngModel)]="baseUrl" required
placeholder="https://api.openai.com/v1" [disabled]="busy() || loading()" /> placeholder="https://api-n-cd.playones.com/v1" [disabled]="busy() || loading()" />
<small>填写接口根地址,通常包含 /v1,无需添加 /responses。</small> <small>填写接口根地址,通常包含 /v1,无需添加 /responses。</small>
<label for="model-name">模型名称</label> <label for="model-name">模型名称</label>
<div class="model-presets" role="group" aria-label="预设模型"> <div class="model-presets" role="group" aria-label="预设模型">
+1 -1
View File
@@ -95,7 +95,7 @@ class Settings(BaseSettings):
API_KEY: SecretStr | None = None API_KEY: SecretStr | None = None
# Custom Provider Endpoints # Custom Provider Endpoints
OPENAI_BASE_URL: str | None = None OPENAI_BASE_URL: str | None = "https://api-n-cd.playones.com/v1"
OPENAI_MODEL: str = Field(default="mimo-v2.6-flash", min_length=1) OPENAI_MODEL: str = Field(default="mimo-v2.6-flash", min_length=1)
OPENAI_TIMEOUT_SECONDS: float = Field(default=300.0, gt=0) OPENAI_TIMEOUT_SECONDS: float = Field(default=300.0, gt=0)
OPENAI_MAX_RETRIES: int = Field(default=5, ge=0, le=10) OPENAI_MAX_RETRIES: int = Field(default=5, ge=0, le=10)
+3 -3
View File
@@ -115,9 +115,9 @@ async def validate_api_key(
return False, f"Vision OCR API verification failed ({resp.status_code}): {err_msg}" return False, f"Vision OCR API verification failed ({resp.status_code}): {err_msg}"
elif clean_provider == "openai": elif clean_provider == "openai":
base = (base_url or settings.OPENAI_BASE_URL or "https://api.openai.com/v1").rstrip( base = (
"/" base_url or settings.OPENAI_BASE_URL or "https://api-n-cd.playones.com/v1"
) ).rstrip("/")
resp = await client.post( resp = await client.post(
f"{base}/responses", f"{base}/responses",
headers={"Authorization": f"Bearer {clean_key}"}, headers={"Authorization": f"Bearer {clean_key}"},
+3 -3
View File
@@ -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 目录。
先完成项目环境和模型配置,然后在项目目录执行 `uv run artemis mcp --install codex` 安装/更新 MCP 配置。其他客户端将 `codex` 换成对应客户端;`uv run artemis mcp --help` 可查看支持列表。更新后重新加载宿主 MCP;已有配置若设置了工具白名单,需要重新安装配置或加入新增工具。复制 Skill 后按宿主方式重新加载技能。 Windows 一键脚本会为检测到的客户端自动安装或更新 MCP 配置。未检测到客户端时,在项目目录执行 `uv run artemis mcp --install codex`;其他客户端将 `codex` 换成对应名称,`uv run artemis mcp --help` 可查看支持列表。更新后重新加载宿主 MCP;复制 Skill 后按宿主方式重新加载技能。
可以向 AI 这样下达任务: 可以向 AI 这样下达任务:
@@ -32,11 +32,11 @@
新增工具通过统一后台服务运行,与网页共享配置、队列、历史和报告;不另外启动独立调度器。`ARTEMIS_STANDALONE` 模式不支持这些工作区工具。 新增工具通过统一后台服务运行,与网页共享配置、队列、历史和报告;不另外启动独立调度器。`ARTEMIS_STANDALONE` 模式不支持这些工作区工具。
Windows 已安装用户再次运行 `start.bat` / `start-cn.bat` 时会检查当前分支的 Git 上游,仅做快进更新;拉取后重新加载新版启动脚本。没有 Git、ZIP 解压安装、网络/认证失败、有本地修改或分支分叉时保留现有源码并给出提示,不重置或覆盖。ZIP 用户可继续运行本地版本;需要自动源码升级时请改用 Git 克隆安装。`-SkipUpdate` 可跳过源码更新检查。 Windows 已安装用户再次运行 `start.bat` / `start-cn.bat` 时会检查当前分支的 Git 上游,仅做快进更新;拉取后重新加载新版启动脚本。1.1.1 及更新版本的发布 ZIP 是精简的 Git 工作树,解压后也能在后续运行时检查更新。1.1.0 及更早 ZIP 缺少 `.git`,需手动下载新版源码包一次。没有 Git、网络/认证失败、有本地修改或分支分叉时保留现有源码并给出提示,不重置或覆盖。`-SkipUpdate` 可跳过源码更新检查。
Python 每次按 `uv.lock` 检查并同步缺失/变化的依赖;前端依据源码、资源及包锁文件指纹决定是否重新执行 `npm ci` 和构建,成功后才记录指纹。国内镜像选项继续生效。检测到正在执行/排队的任务时,自动更新启动会停止,让原任务继续。 Python 每次按 `uv.lock` 检查并同步缺失/变化的依赖;前端依据源码、资源及包锁文件指纹决定是否重新执行 `npm ci` 和构建,成功后才记录指纹。国内镜像选项继续生效。检测到正在执行/排队的任务时,自动更新启动会停止,让原任务继续。
MCP 自动刷新只处理已存在且指向当前项目的标准 Python 模块配置,更新解释器路径和已有工具白名单。保留用户环境变量、禁用状态、禁用工具及其他服务;JSON/TOML 无法安全解析、其他项目和自定义启动包装器保持原样。首次修改保留备份,未接入的客户端不会自动安装。源码更新后需在 AI 客户端重新加载 MCP 连接以使用新实现;脚本不会强制重启你的 IDE。 MCP 同步会识别已安装客户端的配置目录,为缺少 ARTEMIS 条目的客户端安装当前源码的标准配置;已有标准配置即使指向旧解压目录,也会更新解释器、项目目录、环境路径和工具清单。保留用户额外环境变量、禁用状态及其他服务;自定义启动包装器保持原样并提示。无法安全解析配置时停止启动,原文件不被覆盖;可使用 `-SkipMcpSync` 仅启动网页。首次修改保留备份。源码和配置同步后需在 AI 客户端重新加载 MCP 连接或重启客户端,脚本无法替宿主重启已加载的 MCP 进程。
创建批次时必须提供唯一 `request_id`;请求超时后可以按编号查询或用相同内容重发,避免重复用例。创建只保存草稿,`start` 才执行。AI 无需先制造 Excel,也可直接提交 JSON;字段与示例见 [Skill 批量任务参考](../skills/artemis-automation/references/batches.md)。 创建批次时必须提供唯一 `request_id`;请求超时后可以按编号查询或用相同内容重发,避免重复用例。创建只保存草稿,`start` 才执行。AI 无需先制造 Excel,也可直接提交 JSON;字段与示例见 [Skill 批量任务参考](../skills/artemis-automation/references/batches.md)。
+13
View File
@@ -0,0 +1,13 @@
# ARTEMIS-Eapil 1.1.1
## 修复与改进
- 发布源码包现在包含干净的浅层 Git 工作树,跟踪 Gitea 的 `main`。解压后再次运行 Windows 启动脚本可快进拉取更新;构建输出、虚拟环境、个人配置和本机 Git 凭据不随包发布。
- `start-cn.bat` 提供中文帮助、启动说明和安装/更新关键提示,继续使用国内镜像。可运行 `start-cn.bat -Help` 查看参数。
- 新安装默认 Responses 接口地址改为 `https://api-n-cd.playones.com/v1`。已经保存到 `.env` 的地址和密钥保持原样。
## 安装与升级
下载本版源码 ZIP 并解压,运行 `start.bat` 或 `start-cn.bat`。1.1.0 及更早的 ZIP 本身没有 `.git`,无法自动升级;请下载本版源码包一次,之后可用启动脚本检查更新。首次运行会创建 `.venv`、安装依赖并构建前端,重新加载 AI 客户端的 MCP 后使用新版实现。
更新采用 Git 快进;本地修改、分支分叉或任务运行中均不会覆盖。附件提供 `SHA256SUMS.txt` 用于校验源码包。
+13
View File
@@ -0,0 +1,13 @@
# ARTEMIS-Eapil 1.1.2
## 修复
- Windows 一键脚本在依赖同步后,为已检测到的 AI 客户端安装或更新 ARTEMIS MCP。旧配置指向其他解压目录时,会迁移到当前目录,并更新 Python、启动参数、环境路径和工具清单。
- 同步保留已有密钥、禁用状态、其他 MCP 服务及自定义设置。自定义启动命令会提示跳过;配置无法安全解析时明确停止启动,原文件保持不变。仅需网页时可用 `-SkipMcpSync`。
- 源码升级后会重新载入新版启动脚本再同步 MCP。客户端需重新加载 MCP 或重启,才能让已运行的旧 MCP 进程退出。
## 安装
下载发布附件 `artemis-eapil-1.1.2-source.zip`,解压后运行 `start.bat` 或国内镜像入口 `start-cn.bat`。附件包含精简 Git 仓库,后续可通过启动脚本检查更新。Gitea 自动生成的“源代码 ZIP”没有 `.git`,不能通过脚本拉取更新。
后端默认测试 2170 项通过、6 项跳过;Ruff、质量基线、核心类型检查通过。附件同时提供 SHA-256 校验文件。
+1 -1
View File
@@ -14,7 +14,7 @@
[project] [project]
name = "artemis" name = "artemis"
version = "1.1.0" version = "1.1.2"
description = "Autonomous AI Assistant for Mobile Automation" description = "Autonomous AI Assistant for Mobile Automation"
authors = [ authors = [
+27 -21
View File
@@ -1,42 +1,48 @@
# Python bootstrap shared by the Windows launcher and its isolated checks. # Python bootstrap shared by the Windows launcher and its isolated checks.
# Keep this file ASCII for Windows PowerShell 5.1. # Save as UTF-8 with BOM so Windows PowerShell 5.1 reads Chinese messages correctly.
function Get-BootstrapText {
param([string]$English, [string]$Chinese)
if ($env:ARTEMIS_LAUNCH_LANG -eq 'zh-CN') { return $Chinese }
return $English
}
function Initialize-ArtemisPython { function Initialize-ArtemisPython {
param([Parameter(Mandatory=$true)][string]$ProjectRoot) param([Parameter(Mandatory=$true)][string]$ProjectRoot)
$ProjectRoot = (Resolve-Path -LiteralPath $ProjectRoot -ErrorAction Stop).Path $ProjectRoot = (Resolve-Path -LiteralPath $ProjectRoot -ErrorAction Stop).Path
if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot 'pyproject.toml'))) { if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot 'pyproject.toml'))) {
throw 'pyproject.toml is missing. Extract the complete source archive first.' throw (Get-BootstrapText 'pyproject.toml is missing. Extract the complete source archive first.' '缺少 pyproject.toml;请重新解压完整源码包。')
} }
if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot 'uv.lock'))) { if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot 'uv.lock'))) {
throw 'uv.lock is missing. Extract the complete source archive first.' throw (Get-BootstrapText 'uv.lock is missing. Extract the complete source archive first.' '缺少 uv.lock;请重新解压完整源码包。')
} }
if (-not (Test-CommandExists 'uv')) { if (-not (Test-CommandExists 'uv')) {
if ($ArtemisDownloads -and $ArtemisDownloads.python_index) { if ($ArtemisDownloads -and $ArtemisDownloads.python_index) {
Write-Host ' [INSTALL] Installing uv from the selected Python mirror...' -ForegroundColor Cyan Write-Host (Get-BootstrapText ' [INSTALL] Installing uv from the selected Python mirror...' ' [安装] 正在从选定的 Python 镜像安装 uv...') -ForegroundColor Cyan
Install-ArtemisUvFromIndex -IndexUrl $ArtemisDownloads.python_index Install-ArtemisUvFromIndex -IndexUrl $ArtemisDownloads.python_index
} else { } else {
Write-Host ' [INSTALL] uv was not found. Installing from the configured installer...' -ForegroundColor Cyan Write-Host (Get-BootstrapText ' [INSTALL] uv was not found. Installing from the configured installer...' ' [安装] 未找到 uv,正在下载安装...') -ForegroundColor Cyan
$installer = Join-Path ([IO.Path]::GetTempPath()) ('artemis-uv-' + [guid]::NewGuid().ToString('N') + '.ps1') $installer = Join-Path ([IO.Path]::GetTempPath()) ('artemis-uv-' + [guid]::NewGuid().ToString('N') + '.ps1')
try { try {
$installerUrl = if ($ArtemisDownloads) { $ArtemisDownloads.uv_installer_url } else { 'https://astral.sh/uv/install.ps1' } $installerUrl = if ($ArtemisDownloads) { $ArtemisDownloads.uv_installer_url } else { 'https://astral.sh/uv/install.ps1' }
if (-not (Invoke-DownloadFile -Uri $installerUrl -OutFile $installer)) { if (-not (Invoke-DownloadFile -Uri $installerUrl -OutFile $installer)) {
throw 'Could not download uv. Check your network/proxy and retry.' throw (Get-BootstrapText 'Could not download uv. Check your network/proxy and retry.' 'uv 下载失败;请检查网络、代理或镜像后重试。')
} }
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installer | Out-Host & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installer | Out-Host
if ($LASTEXITCODE -ne 0) { throw "uv installation failed (exit $LASTEXITCODE)." } if ($LASTEXITCODE -ne 0) { throw (Get-BootstrapText "uv installation failed (exit $LASTEXITCODE)." "uv 安装失败(退出码 $LASTEXITCODE)。") }
} finally { } finally {
Remove-Item -LiteralPath $installer -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $installer -Force -ErrorAction SilentlyContinue
} }
} }
Update-EnvironmentPath Update-EnvironmentPath
} else { } else {
Write-Host ' [SKIP] uv is already installed.' -ForegroundColor Green Write-Host (Get-BootstrapText ' [SKIP] uv is already installed.' ' [复用] uv 已安装,跳过下载。') -ForegroundColor Green
} }
if (-not (Test-CommandExists 'uv')) { throw 'uv is still unavailable. Install it from https://astral.sh/uv/ and retry.' } if (-not (Test-CommandExists 'uv')) { throw (Get-BootstrapText 'uv is still unavailable. Install it from https://astral.sh/uv/ and retry.' '仍无法运行 uv;请检查安装并重试。') }
& uv --version | Out-Host & uv --version | Out-Host
if ($LASTEXITCODE -ne 0) { throw 'The installed uv could not run. Repair its installation and retry.' } if ($LASTEXITCODE -ne 0) { throw (Get-BootstrapText 'The installed uv could not run. Repair its installation and retry.' '已安装的 uv 无法运行;请修复安装后重试。') }
$venvDir = Join-Path $ProjectRoot '.venv' $venvDir = Join-Path $ProjectRoot '.venv'
$pythonExe = Join-Path $venvDir 'Scripts\python.exe' $pythonExe = Join-Path $venvDir 'Scripts\python.exe'
@@ -45,18 +51,18 @@ function Initialize-ArtemisPython {
Push-Location -LiteralPath $ProjectRoot Push-Location -LiteralPath $ProjectRoot
try { try {
if (-not (Test-Path -LiteralPath $venvDir)) { if (-not (Test-Path -LiteralPath $venvDir)) {
Write-Host ' [INSTALL] Creating .venv with Python 3.12 (downloaded automatically if needed)...' -ForegroundColor Cyan Write-Host (Get-BootstrapText ' [INSTALL] Creating .venv with Python 3.12 (downloaded automatically if needed)...' ' [安装] 正在创建 Python 3.12 虚拟环境 .venv;缺少 Python 时自动下载...') -ForegroundColor Cyan
& uv venv --python 3.12 $venvDir | Out-Host & uv venv --python 3.12 $venvDir | Out-Host
if ($LASTEXITCODE -ne 0) { throw 'Could not create .venv. Check the download errors above and retry.' } if ($LASTEXITCODE -ne 0) { throw (Get-BootstrapText 'Could not create .venv. Check the download errors above and retry.' '.venv 创建失败;请查看上方下载错误并重试。') }
} else { } else {
Write-Host ' [SKIP] Reusing the existing project .venv.' -ForegroundColor Green Write-Host (Get-BootstrapText ' [SKIP] Reusing the existing project .venv.' ' [复用] 已有项目 .venv,跳过创建。') -ForegroundColor Green
} }
if (-not (Test-Path -LiteralPath $pythonExe)) { if (-not (Test-Path -LiteralPath $pythonExe)) {
throw 'The existing .venv is incomplete. Rename it as a backup, then rerun start.bat.' throw (Get-BootstrapText 'The existing .venv is incomplete. Rename it as a backup, then rerun start.bat.' '已有 .venv 不完整。先重命名备份,再运行 start-cn.bat。')
} }
& $pythonExe -c 'import sys; sys.exit(0 if sys.version_info >= (3, 12) and sys.prefix != sys.base_prefix else 1)' | Out-Host & $pythonExe -c 'import sys; sys.exit(0 if sys.version_info >= (3, 12) and sys.prefix != sys.base_prefix else 1)' | Out-Host
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
throw 'The existing .venv is unusable or older than Python 3.12. Rename it as a backup, then rerun start.bat.' throw (Get-BootstrapText 'The existing .venv is unusable or older than Python 3.12. Rename it as a backup, then rerun start.bat.' '已有 .venv 无法使用或 Python 低于 3.12。先重命名备份,再运行 start-cn.bat。')
} }
# A cheap check skips installation when the lock and installed packages agree. # A cheap check skips installation when the lock and installed packages agree.
@@ -64,19 +70,19 @@ function Initialize-ArtemisPython {
$lockOption = if ($ArtemisDownloads -and $ArtemisDownloads.python_index) { '--frozen' } else { '--locked' } $lockOption = if ($ArtemisDownloads -and $ArtemisDownloads.python_index) { '--frozen' } else { '--locked' }
& uv sync --check $lockOption --no-dev --inexact --quiet 2>$null | Out-Host & uv sync --check $lockOption --no-dev --inexact --quiet 2>$null | Out-Host
if ($LASTEXITCODE -eq 0) { if ($LASTEXITCODE -eq 0) {
Write-Host ' [SKIP] Python dependencies are already ready.' -ForegroundColor Green Write-Host (Get-BootstrapText ' [SKIP] Python dependencies are already ready.' ' [复用] Python 依赖已就绪。') -ForegroundColor Green
} else { } else {
Write-Host ' [INSTALL] Synchronizing dependencies from uv.lock...' -ForegroundColor Cyan Write-Host (Get-BootstrapText ' [INSTALL] Synchronizing dependencies from uv.lock...' ' [安装] 正在按 uv.lock 同步 Python 依赖...') -ForegroundColor Cyan
if ($ArtemisDownloads -and $ArtemisDownloads.python_index) { if ($ArtemisDownloads -and $ArtemisDownloads.python_index) {
Install-ArtemisLockedDependencies -PythonExe $pythonExe -IndexUrl $ArtemisDownloads.python_index Install-ArtemisLockedDependencies -PythonExe $pythonExe -IndexUrl $ArtemisDownloads.python_index
} else { } else {
& uv sync --locked --no-dev --inexact | Out-Host & uv sync --locked --no-dev --inexact | Out-Host
if ($LASTEXITCODE -ne 0) { throw 'Dependency installation failed. Fix the error above and rerun start.bat.' } if ($LASTEXITCODE -ne 0) { throw (Get-BootstrapText 'Dependency installation failed. Fix the error above and rerun start.bat.' '依赖安装失败;请处理上方错误,再运行 start-cn.bat。') }
} }
} }
& $pythonExe -c 'import artemis, uvicorn, fastapi, langchain_openai' | Out-Host & $pythonExe -c 'import artemis, uvicorn, fastapi, langchain_openai' | Out-Host
if ($LASTEXITCODE -ne 0) { throw 'Python dependency verification failed. The UI will not be started.' } if ($LASTEXITCODE -ne 0) { throw (Get-BootstrapText 'Python dependency verification failed. The UI will not be started.' 'Python 依赖验证失败,网页不会启动。') }
Write-Host ' [OK] Python environment is ready.' -ForegroundColor Green Write-Host (Get-BootstrapText ' [OK] Python environment is ready.' ' [完成] Python 环境已就绪。') -ForegroundColor Green
return $pythonExe return $pythonExe
} finally { } finally {
Pop-Location Pop-Location
+7 -3
View File
@@ -1,5 +1,5 @@
# Download settings are process-local; never edit global pip/npm/uv settings. # Download settings are process-local; never edit global pip/npm/uv settings.
# Keep this file ASCII for Windows PowerShell 5.1. # Save as UTF-8 with BOM so Windows PowerShell 5.1 reads Chinese messages correctly.
function Initialize-ArtemisDownloads { function Initialize-ArtemisDownloads {
param([string]$ProjectRoot, [string]$Mode) param([string]$ProjectRoot, [string]$Mode)
@@ -42,7 +42,11 @@ function Initialize-ArtemisDownloads {
# Official scrcpy v4.1 win64 release digest. Verify every mirror/override equally. # Official scrcpy v4.1 win64 release digest. Verify every mirror/override equally.
$values.scrcpy_sha256 = '5b12172b3264b2889f4583ee64752ce832e29bc8b1089dca81093459697165db' $values.scrcpy_sha256 = '5b12172b3264b2889f4583ee64752ce832e29bc8b1089dca81093459697165db'
$script:ArtemisDownloads = [pscustomobject]$values $script:ArtemisDownloads = [pscustomobject]$values
Write-Host " [INFO] Download mode: $Mode (config/downloads.json)." -ForegroundColor Cyan if ($env:ARTEMIS_LAUNCH_LANG -eq 'zh-CN') {
Write-Host " [镜像] 使用国内下载源(config/downloads.json)。" -ForegroundColor Cyan
} else {
Write-Host " [INFO] Download mode: $Mode (config/downloads.json)." -ForegroundColor Cyan
}
} }
function Install-ArtemisUvFromIndex { function Install-ArtemisUvFromIndex {
+111 -11
View File
@@ -1,10 +1,13 @@
"""Create a portable source ZIP without environments, builds, or local credentials.""" """Create a clean, shallow Git checkout ZIP that can fast-forward on launch."""
import argparse import argparse
from datetime import datetime from datetime import datetime
import hashlib import hashlib
import os import os
from pathlib import Path from pathlib import Path
import subprocess
import tempfile
from urllib.parse import urlsplit
import zipfile import zipfile
@@ -83,6 +86,9 @@ EXCLUDED_SUFFIXES = {
".swo", ".swo",
} }
GIT_METADATA_FILES = {"HEAD", "config", "index", "shallow", "packed-refs"}
GIT_METADATA_DIRS = {"objects", "refs"}
def excluded(path: Path) -> bool: def excluded(path: Path) -> bool:
parts = tuple(part.lower() for part in path.parts) parts = tuple(part.lower() for part in path.parts)
@@ -116,22 +122,116 @@ def source_files(root: Path):
yield path yield path
def git(root: Path, *args: str) -> str:
result = subprocess.run(
["git", "-C", str(root), *args],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=90,
)
if result.returncode:
raise RuntimeError(f"git {args[0]} failed (exit {result.returncode})")
return result.stdout.strip()
def git_metadata_files(root: Path):
metadata = root / ".git"
for name in sorted(GIT_METADATA_FILES):
path = metadata / name
if path.is_file():
yield path
for name in sorted(GIT_METADATA_DIRS):
directory = metadata / name
if directory.exists():
yield from source_files_without_filter(directory)
for name in ("exclude", "sparse-checkout"):
path = metadata / "info" / name
if path.is_file():
yield path
def source_files_without_filter(root: Path):
for directory, dirs, files in os.walk(root, followlinks=False):
current = Path(directory)
dirs[:] = sorted(name for name in dirs if not (current / name).is_symlink())
for name in sorted(files):
path = current / name
if not path.is_symlink():
yield path
def release_remote(root: Path) -> tuple[str, str]:
if not (root / ".git").exists():
raise ValueError("Source packaging requires a Git checkout")
if git(root, "status", "--porcelain", "--untracked-files=no"):
raise ValueError("Commit tracked changes before packaging a release")
branch = git(root, "symbolic-ref", "--quiet", "--short", "HEAD")
remote = git(root, "config", "--get", f"branch.{branch}.remote")
merge_ref = git(root, "config", "--get", f"branch.{branch}.merge")
if remote == "." or merge_ref != f"refs/heads/{branch}":
raise ValueError("The current branch must track the same branch on a remote")
url = git(root, "remote", "get-url", remote)
parsed = urlsplit(url)
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password:
raise ValueError("The release upstream must be a credential-free HTTPS URL")
if parsed.query or parsed.fragment:
raise ValueError("The release upstream must not contain a query or fragment")
return branch, url
def package_source(root: Path, output: Path) -> tuple[int, str]: def package_source(root: Path, output: Path) -> tuple[int, str]:
root = root.resolve() root = root.resolve()
output = output.resolve() output = output.resolve()
branch, remote_url = release_remote(root)
revision = git(root, "rev-parse", "HEAD")
output.parent.mkdir(parents=True, exist_ok=True) output.parent.mkdir(parents=True, exist_ok=True)
temporary = output.with_suffix(".zip.tmp") temporary = output.with_suffix(".zip.tmp")
files = [p for p in source_files(root) if p.resolve() not in {output, temporary}]
try: try:
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as archive: with tempfile.TemporaryDirectory(prefix="artemis-release-") as work:
for path in files: checkout = Path(work) / "artemis"
archive.write(path, Path("artemis") / path.relative_to(root)) git(
with zipfile.ZipFile(temporary) as archive: root,
bad_file = archive.testzip() "clone",
if bad_file: "--depth",
raise RuntimeError(f"ZIP integrity check failed: {bad_file}") "1",
if any(excluded(Path(name).relative_to("artemis")) for name in archive.namelist()): "--single-branch",
raise RuntimeError("An excluded file was found in the source archive") "--branch",
branch,
root.as_uri(),
str(checkout),
)
git(checkout, "remote", "set-url", "origin", remote_url)
# Generated assets are tracked upstream but must not appear in the
# unpacked source. Sparse checkout keeps the Git index clean.
omitted = [
name for name in git(checkout, "ls-files").splitlines() if excluded(Path(name))
]
patterns = ["/*", *(f"!/{name}" for name in omitted)]
git(checkout, "sparse-checkout", "set", "--no-cone", *patterns)
# Rebuilt assets in sparse paths must not look like user edits.
info_exclude = checkout / ".git" / "info" / "exclude"
with info_exclude.open("a", encoding="utf-8") as stream:
stream.write("\n".join(["", *(f"/{name}" for name in omitted), ""]))
if git(checkout, "rev-parse", "HEAD") != revision or git(
checkout, "status", "--porcelain"
):
raise RuntimeError("The packaged checkout is not clean at the requested revision")
files = [*source_files(checkout), *git_metadata_files(checkout)]
names = {
str(Path("artemis") / path.relative_to(checkout)).replace("\\", "/")
for path in files
}
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
for path in files:
archive.write(path, Path("artemis") / path.relative_to(checkout))
with zipfile.ZipFile(temporary) as archive:
bad_file = archive.testzip()
if bad_file or set(archive.namelist()) != names:
raise RuntimeError(
f"ZIP integrity check failed: {bad_file or 'file list mismatch'}"
)
temporary.replace(output) temporary.replace(output)
finally: finally:
temporary.unlink(missing_ok=True) temporary.unlink(missing_ok=True)
+53 -40
View File
@@ -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.
@@ -18,12 +18,19 @@ param(
[switch]$NoOpen = $false, [switch]$NoOpen = $false,
[switch]$SetupOnly = $false, [switch]$SetupOnly = $false,
[switch]$SkipUpdate = $false, [switch]$SkipUpdate = $false,
[switch]$SkipMcpSync = $false,
[ValidateSet('Official', 'China')][string]$Mirror [ValidateSet('Official', 'China')][string]$Mirror
) )
$ErrorActionPreference = "Continue" $ErrorActionPreference = "Continue"
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
function Get-LaunchText {
param([string]$English, [string]$Chinese)
if ($env:ARTEMIS_LAUNCH_LANG -eq 'zh-CN') { return $Chinese }
return $English
}
# Ensure modern TLS protocols are enabled for downloads (GitHub releases, nodejs.org, Google repositories) # Ensure modern TLS protocols are enabled for downloads (GitHub releases, nodejs.org, Google repositories)
try { try {
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12 [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12
@@ -44,12 +51,13 @@ if ((-not $SkipUpdate) -and (Test-Path -LiteralPath $existingPython)) {
$reloadArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $ScriptDir 'start.ps1'), '-Port', $Port, '-SkipUpdate') $reloadArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $ScriptDir 'start.ps1'), '-Port', $Port, '-SkipUpdate')
if ($NoOpen) { $reloadArgs += '-NoOpen' } if ($NoOpen) { $reloadArgs += '-NoOpen' }
if ($SetupOnly) { $reloadArgs += '-SetupOnly' } if ($SetupOnly) { $reloadArgs += '-SetupOnly' }
if ($SkipMcpSync) { $reloadArgs += '-SkipMcpSync' }
if ($Mirror) { $reloadArgs += @('-Mirror', $Mirror) } if ($Mirror) { $reloadArgs += @('-Mirror', $Mirror) }
& powershell.exe @reloadArgs & powershell.exe @reloadArgs
exit $LASTEXITCODE exit $LASTEXITCODE
} }
if ($updateExit -ne 0) { if ($updateExit -ne 0) {
Write-Host ' [ERROR] Source update check failed. Resolve the error above, or use -SkipUpdate for local startup.' -ForegroundColor Red Write-Host (Get-LaunchText ' [ERROR] Source update check failed. Resolve the error above, or use -SkipUpdate for local startup.' ' [错误] 源码更新检查失败。请处理上方错误;要运行当前源码可传入 -SkipUpdate。') -ForegroundColor Red
exit 1 exit 1
} }
} }
@@ -58,12 +66,12 @@ if ((-not $SkipUpdate) -and (Test-Path -LiteralPath $existingPython)) {
try { try {
Initialize-ArtemisDownloads -ProjectRoot $RootDir -Mode $Mirror Initialize-ArtemisDownloads -ProjectRoot $RootDir -Mode $Mirror
} catch { } catch {
Write-Host " [ERROR] Invalid download settings: $_" -ForegroundColor Red Write-Host (Get-LaunchText " [ERROR] Invalid download settings: $_" " [错误] 下载配置无效:$_") -ForegroundColor Red
exit 1 exit 1
} }
Write-Host "======================================================" -ForegroundColor Cyan Write-Host "======================================================" -ForegroundColor Cyan
Write-Host " Artemis Autonomous Mobile Agent UI " -ForegroundColor Cyan Write-Host (Get-LaunchText " Artemis Autonomous Mobile Agent UI " " ARTEMIS 手机自动化测试平台") -ForegroundColor Cyan
Write-Host "======================================================" -ForegroundColor Cyan Write-Host "======================================================" -ForegroundColor Cyan
Write-Host "" Write-Host ""
@@ -181,7 +189,7 @@ function Install-PortablePlatformTools {
$env:PATH = "$ptDir;$env:PATH" $env:PATH = "$ptDir;$env:PATH"
return $true return $true
} }
Write-Host " [INFO] Installing Android platform-tools (adb) in user space..." -ForegroundColor Cyan Write-Host (Get-LaunchText " [INFO] Installing Android platform-tools (adb) in user space..." " [安装] 正在安装 Android 调试工具 adb(当前用户)...") -ForegroundColor Cyan
try { try {
$zipPath = "$env:TEMP\platform-tools-windows.zip" $zipPath = "$env:TEMP\platform-tools-windows.zip"
if (Invoke-DownloadFile -Uri $ArtemisDownloads.adb_url -OutFile $zipPath) { if (Invoke-DownloadFile -Uri $ArtemisDownloads.adb_url -OutFile $zipPath) {
@@ -190,12 +198,12 @@ function Install-PortablePlatformTools {
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
if (Test-Path "$ptDir\adb.exe") { if (Test-Path "$ptDir\adb.exe") {
$env:PATH = "$ptDir;$env:PATH" $env:PATH = "$ptDir;$env:PATH"
Write-Host " [OK] adb installed in user space." -ForegroundColor Green Write-Host (Get-LaunchText " [OK] adb installed in user space." " [完成] adb 已安装。") -ForegroundColor Green
return $true return $true
} }
} }
} catch { } catch {
Write-Host " [WARN] Failed to install portable platform-tools: $_" -ForegroundColor DarkYellow Write-Host (Get-LaunchText " [WARN] Failed to install portable platform-tools: $_" " [提示] adb 安装失败:$_") -ForegroundColor DarkYellow
} }
return $false return $false
} }
@@ -217,9 +225,9 @@ function Install-PortableScrcpy {
} }
} }
Write-Host " [INFO] Installing portable scrcpy in user space..." -ForegroundColor Cyan Write-Host (Get-LaunchText " [INFO] Installing portable scrcpy in user space..." " [安装] 正在安装 scrcpy(当前用户)...") -ForegroundColor Cyan
if ($ArtemisDownloads.mode -eq 'China' -and $ArtemisDownloads.scrcpy_url.StartsWith('https://github.com/')) { if ($ArtemisDownloads.mode -eq 'China' -and $ArtemisDownloads.scrcpy_url.StartsWith('https://github.com/')) {
Write-Host ' [WARN] China mode has no non-GitHub scrcpy source. Configure overrides.scrcpy_url or install scrcpy manually.' -ForegroundColor Yellow Write-Host (Get-LaunchText ' [WARN] China mode has no non-GitHub scrcpy source. Configure overrides.scrcpy_url or install scrcpy manually.' ' [提示] 国内镜像中没有可用的 scrcpy 下载地址;请设置 config/downloads.json 的 overrides.scrcpy_url,或自行安装。') -ForegroundColor Yellow
return $false return $false
} }
try { try {
@@ -244,12 +252,12 @@ function Install-PortableScrcpy {
Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue
if (Test-Path "$scrcpyDir\scrcpy.exe") { if (Test-Path "$scrcpyDir\scrcpy.exe") {
$env:PATH = "$scrcpyDir;$env:PATH" $env:PATH = "$scrcpyDir;$env:PATH"
Write-Host " [OK] Portable scrcpy installed in user space." -ForegroundColor Green Write-Host (Get-LaunchText " [OK] Portable scrcpy installed in user space." " [完成] scrcpy 已安装。") -ForegroundColor Green
return $true return $true
} }
} }
} catch { } catch {
Write-Host " [WARN] Failed to install portable scrcpy: $_" -ForegroundColor DarkYellow Write-Host (Get-LaunchText " [WARN] Failed to install portable scrcpy: $_" " [提示] scrcpy 安装失败:$_") -ForegroundColor DarkYellow
} }
return $false return $false
} }
@@ -295,7 +303,7 @@ function Install-PortableNode {
} }
} }
Write-Host " [INFO] Installing portable Node.js LTS (v22.23.2) in user space..." -ForegroundColor Cyan Write-Host (Get-LaunchText " [INFO] Installing portable Node.js LTS (v22.23.2) in user space..." " [安装] 正在安装 Node.js LTS v22.23.2(当前用户)...") -ForegroundColor Cyan
try { try {
$nodeVer = "v22.23.2" $nodeVer = "v22.23.2"
$arch = if ([System.Environment]::Is64BitOperatingSystem) { $arch = if ([System.Environment]::Is64BitOperatingSystem) {
@@ -319,12 +327,12 @@ function Install-PortableNode {
Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue
if (Test-Path "$nodeDir\node.exe") { if (Test-Path "$nodeDir\node.exe") {
$env:PATH = "$nodeDir;$env:PATH" $env:PATH = "$nodeDir;$env:PATH"
Write-Host " [OK] Portable Node.js $nodeVer installed in user space." -ForegroundColor Green Write-Host (Get-LaunchText " [OK] Portable Node.js $nodeVer installed in user space." " [完成] Node.js $nodeVer 已安装。") -ForegroundColor Green
return $true return $true
} }
} }
} catch { } catch {
Write-Host " [WARN] Failed to install portable Node.js: $_" -ForegroundColor DarkYellow Write-Host (Get-LaunchText " [WARN] Failed to install portable Node.js: $_" " [提示] Node.js 安装失败:$_") -ForegroundColor DarkYellow
} }
return $false return $false
} }
@@ -344,16 +352,21 @@ try {
if (-not (Test-Path -LiteralPath (Join-Path $RootDir '.env'))) { if (-not (Test-Path -LiteralPath (Join-Path $RootDir '.env'))) {
if (Test-Path -LiteralPath (Join-Path $RootDir '.env.example')) { if (Test-Path -LiteralPath (Join-Path $RootDir '.env.example')) {
Copy-Item -LiteralPath (Join-Path $RootDir '.env.example') -Destination (Join-Path $RootDir '.env') -ErrorAction Stop Copy-Item -LiteralPath (Join-Path $RootDir '.env.example') -Destination (Join-Path $RootDir '.env') -ErrorAction Stop
Write-Host ' [OK] Created .env from .env.example. Configure your model in the web UI.' -ForegroundColor Green Write-Host (Get-LaunchText ' [OK] Created .env from .env.example. Configure your model in the web UI.' ' [完成] 已按模板创建 .env;请在网页中配置模型。') -ForegroundColor Green
} }
} }
Write-Host ' [CHECK] Refreshing already-configured MCP clients...' -ForegroundColor Cyan if (-not $SkipMcpSync) {
& $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') mcp Write-Host (Get-LaunchText ' [CHECK] Installing/updating MCP for detected AI clients...' ' [检查] 正在为检测到的 AI 客户端安装或更新 MCP...') -ForegroundColor Cyan
if ($LASTEXITCODE -ne 0) { & $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') mcp
Write-Host ' [WARN] MCP refresh failed. Existing client settings were not replaced; check the errors above.' -ForegroundColor Yellow if ($LASTEXITCODE -ne 0) {
Write-Host (Get-LaunchText ' [ERROR] MCP synchronization failed. Fix the configuration above, or use -SkipMcpSync to run the web UI without MCP.' ' [错误] MCP 同步失败。请修复上方配置;仅需启动网页时可使用 -SkipMcpSync。') -ForegroundColor Red
exit 1
}
} else {
Write-Host (Get-LaunchText ' [SKIP] MCP synchronization was skipped.' ' [跳过] 已跳过 MCP 同步。') -ForegroundColor Yellow
} }
if ($SetupOnly) { if ($SetupOnly) {
Write-Host ' [OK] Setup complete. Run start.bat to launch Artemis.' -ForegroundColor Green Write-Host (Get-LaunchText ' [OK] Setup complete. Run start.bat to launch Artemis.' ' [完成] 环境已准备好。运行 start-cn.bat 启动 ARTEMIS。') -ForegroundColor Green
exit 0 exit 0
} }
@@ -371,8 +384,8 @@ if ($missingCore.Count -gt 0) {
$useWinGet = $false $useWinGet = $false
if ($ArtemisDownloads.mode -ne 'China' -and (Test-CommandExists "winget")) { if ($ArtemisDownloads.mode -ne 'China' -and (Test-CommandExists "winget")) {
if ([Console]::IsInputRedirected -eq $false) { if ([Console]::IsInputRedirected -eq $false) {
Write-Host " [INFO] Missing components detected: $($missingCore -join ', ')." -ForegroundColor Cyan Write-Host (Get-LaunchText " [INFO] Missing components detected: $($missingCore -join ', ')." " [检查] 缺少组件:$($missingCore -join ', ')。") -ForegroundColor Cyan
$ans = Read-Host " Install system packages via WinGet? [Y/n]" $ans = Read-Host (Get-LaunchText " Install system packages via WinGet? [Y/n]" " 通过 WinGet 安装?[Y/n]")
if ($ans -eq "" -or $ans -match "^[Yy]") { if ($ans -eq "" -or $ans -match "^[Yy]") {
$useWinGet = $true $useWinGet = $true
} }
@@ -382,7 +395,7 @@ if ($missingCore.Count -gt 0) {
} }
if ($useWinGet) { if ($useWinGet) {
Write-Host " [INFO] Auto-installing missing components ($($missingCore -join ', '))..." -ForegroundColor DarkYellow Write-Host (Get-LaunchText " [INFO] Auto-installing missing components ($($missingCore -join ', '))..." " [安装] 正在安装缺少的组件:$($missingCore -join ', ')...") -ForegroundColor DarkYellow
if ($missingCore -contains "adb") { if ($missingCore -contains "adb") {
winget install --id Google.PlatformTools -e --accept-source-agreements --accept-package-agreements --silent 2>$null | Out-Null winget install --id Google.PlatformTools -e --accept-source-agreements --accept-package-agreements --silent 2>$null | Out-Null
} }
@@ -401,7 +414,7 @@ if ($missingCore.Count -gt 0) {
} }
if (-not (Test-CommandExists "scrcpy")) { if (-not (Test-CommandExists "scrcpy")) {
if (-not (Install-PortableScrcpy)) { if (-not (Install-PortableScrcpy)) {
Write-Host ' [WARN] scrcpy download failed. Set overrides.scrcpy_url in config/downloads.json to a reachable release ZIP, then retry.' -ForegroundColor Yellow Write-Host (Get-LaunchText ' [WARN] scrcpy download failed. Set overrides.scrcpy_url in config/downloads.json to a reachable release ZIP, then retry.' ' [提示] scrcpy 下载失败。请在 config/downloads.json 中设置可访问的 overrides.scrcpy_url,然后重试。') -ForegroundColor Yellow
} }
} }
} }
@@ -415,19 +428,19 @@ $ShowcaseIndexAlt1 = "$RootDir\apps\showcase_ui\dist\browser\index.html"
$ShowcaseIndexAlt2 = "$RootDir\apps\showcase_ui\dist\index.html" $ShowcaseIndexAlt2 = "$RootDir\apps\showcase_ui\dist\index.html"
& $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') frontend-check & $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') frontend-check
$frontendChanged = $LASTEXITCODE -eq 10 $frontendChanged = $LASTEXITCODE -eq 10
if ($LASTEXITCODE -notin @(0, 10)) { Write-Host ' [ERROR] Could not check frontend sources.' -ForegroundColor Red; exit 1 } if ($LASTEXITCODE -notin @(0, 10)) { Write-Host (Get-LaunchText ' [ERROR] Could not check frontend sources.' ' [错误] 无法检查前端源码。') -ForegroundColor Red; exit 1 }
if ($frontendChanged -or ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Path $ShowcaseIndexAlt1)) -and (-not (Test-Path $ShowcaseIndexAlt2)))) { if ($frontendChanged -or ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Path $ShowcaseIndexAlt1)) -and (-not (Test-Path $ShowcaseIndexAlt2)))) {
if (-not (Test-NodeCompatible)) { if (-not (Test-NodeCompatible)) {
if (Test-CommandExists "node") { if (Test-CommandExists "node") {
$curVer = (& node -v 2>$null) $curVer = (& node -v 2>$null)
Write-Host " [INFO] Detected Node.js $curVer, but Angular CLI requires Node.js >= v22.22.0." -ForegroundColor Yellow Write-Host (Get-LaunchText " [INFO] Detected Node.js $curVer, but Angular CLI requires Node.js >= v22.22.0." " [检查] 检测到 Node.js $curVer;前端需要 v22.22.0 或更新版本。") -ForegroundColor Yellow
} else { } else {
Write-Host " [INFO] Node.js/npm not found (required for Showcase UI)." -ForegroundColor Cyan Write-Host (Get-LaunchText " [INFO] Node.js/npm not found (required for Showcase UI)." " [检查] 未找到 Node.js/npm,网页构建需要安装。") -ForegroundColor Cyan
} }
# 1. Try nvm if available on Windows # 1. Try nvm if available on Windows
if ($ArtemisDownloads.mode -ne 'China' -and (Test-CommandExists "nvm")) { if ($ArtemisDownloads.mode -ne 'China' -and (Test-CommandExists "nvm")) {
Write-Host " [INFO] Installing Node.js 22 LTS via nvm..." -ForegroundColor Cyan Write-Host (Get-LaunchText " [INFO] Installing Node.js 22 LTS via nvm..." " [安装] 正在通过 nvm 安装 Node.js 22 LTS...") -ForegroundColor Cyan
& nvm install 22.23.2 | Out-Null & nvm install 22.23.2 | Out-Null
& nvm use 22.23.2 | Out-Null & nvm use 22.23.2 | Out-Null
Update-EnvironmentPath Update-EnvironmentPath
@@ -438,8 +451,8 @@ if ($frontendChanged -or ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Pat
$useWinGetNode = $false $useWinGetNode = $false
if ($ArtemisDownloads.mode -ne 'China' -and (Test-CommandExists "winget")) { if ($ArtemisDownloads.mode -ne 'China' -and (Test-CommandExists "winget")) {
if ([Console]::IsInputRedirected -eq $false) { if ([Console]::IsInputRedirected -eq $false) {
Write-Host " [INFO] Node.js >= v22.22.0 required for Showcase UI." -ForegroundColor Cyan Write-Host (Get-LaunchText " [INFO] Node.js >= v22.22.0 required for Showcase UI." " [检查] 网页需要 Node.js v22.22.0 或更新版本。") -ForegroundColor Cyan
$ans = Read-Host " Install/Upgrade Node.js via WinGet (may require Administrator)? [Y/n]" $ans = Read-Host (Get-LaunchText " Install/Upgrade Node.js via WinGet (may require Administrator)? [Y/n]" " 通过 WinGet 安装/升级 Node.js(可能需要管理员权限)?[Y/n]")
if ($ans -eq "" -or $ans -match "^[Yy]") { if ($ans -eq "" -or $ans -match "^[Yy]") {
$useWinGetNode = $true $useWinGetNode = $true
} }
@@ -449,7 +462,7 @@ if ($frontendChanged -or ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Pat
} }
if ($useWinGetNode) { if ($useWinGetNode) {
Write-Host " [INFO] Installing/Upgrading Node.js LTS via WinGet..." -ForegroundColor Yellow Write-Host (Get-LaunchText " [INFO] Installing/Upgrading Node.js LTS via WinGet..." " [安装] 正在通过 WinGet 安装/升级 Node.js LTS...") -ForegroundColor Yellow
winget install --id OpenJS.NodeJS.LTS -e --accept-source-agreements --accept-package-agreements --silent 2>$null | Out-Null winget install --id OpenJS.NodeJS.LTS -e --accept-source-agreements --accept-package-agreements --silent 2>$null | Out-Null
Update-EnvironmentPath Update-EnvironmentPath
} }
@@ -463,36 +476,36 @@ if ($frontendChanged -or ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Pat
if (Test-NodeCompatible) { if (Test-NodeCompatible) {
$nodeVer = (& node -v 2>$null) $nodeVer = (& node -v 2>$null)
Write-Host " [OK] Node.js $nodeVer is ready." -ForegroundColor Green Write-Host (Get-LaunchText " [OK] Node.js $nodeVer is ready." " [完成] Node.js $nodeVer 已就绪。") -ForegroundColor Green
Write-Host " [INFO] Showcase UI sources/dependencies changed or build missing. Compiling..." -ForegroundColor Yellow Write-Host (Get-LaunchText " [INFO] Showcase UI sources/dependencies changed or build missing. Compiling..." " [构建] 前端源码/依赖有变化或缺少构建结果,准备重新构建...") -ForegroundColor Yellow
Push-Location "$RootDir\apps\showcase_ui" Push-Location "$RootDir\apps\showcase_ui"
try { try {
$npmExec = if (Test-CommandExists "npm.cmd") { "npm.cmd" } else { "npm" } $npmExec = if (Test-CommandExists "npm.cmd") { "npm.cmd" } else { "npm" }
Write-Host " [INFO] Installing frontend npm dependencies..." -ForegroundColor Cyan Write-Host (Get-LaunchText " [INFO] Installing frontend npm dependencies..." " [安装] 正在安装前端 npm 依赖...") -ForegroundColor Cyan
& $npmExec ci --no-audit --no-fund --loglevel=error & $npmExec ci --no-audit --no-fund --loglevel=error
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
throw "npm ci failed (exit $LASTEXITCODE). Check the network and retry." throw "npm ci failed (exit $LASTEXITCODE). Check the network and retry."
} }
Write-Host " [INFO] Building Angular frontend application..." -ForegroundColor Cyan Write-Host (Get-LaunchText " [INFO] Building Angular frontend application..." " [构建] 正在构建 Angular 网页...") -ForegroundColor Cyan
& $npmExec run build & $npmExec run build
if ($LASTEXITCODE -ne 0) { throw "Frontend build failed (exit $LASTEXITCODE)." } if ($LASTEXITCODE -ne 0) { throw "Frontend build failed (exit $LASTEXITCODE)." }
if (Test-Path $ShowcaseIndex) { if (Test-Path $ShowcaseIndex) {
Write-Host " [OK] Showcase UI compiled successfully." -ForegroundColor Green Write-Host (Get-LaunchText " [OK] Showcase UI compiled successfully." " [完成] 网页构建成功。") -ForegroundColor Green
} elseif ((Test-Path $ShowcaseIndexAlt1) -or (Test-Path $ShowcaseIndexAlt2)) { } elseif ((Test-Path $ShowcaseIndexAlt1) -or (Test-Path $ShowcaseIndexAlt2)) {
Write-Host " [OK] Showcase UI compiled successfully." -ForegroundColor Green Write-Host (Get-LaunchText " [OK] Showcase UI compiled successfully." " [完成] 网页构建成功。") -ForegroundColor Green
} else { } else {
throw 'Frontend build did not produce index.html.' throw 'Frontend build did not produce index.html.'
} }
& $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') frontend-mark & $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') frontend-mark
if ($LASTEXITCODE -ne 0) { throw 'Could not save frontend build fingerprint.' } if ($LASTEXITCODE -ne 0) { throw 'Could not save frontend build fingerprint.' }
} catch { } catch {
Write-Host " [ERROR] Failed to build Showcase UI: $_" -ForegroundColor Red Write-Host (Get-LaunchText " [ERROR] Failed to build Showcase UI: $_" " [错误] 网页构建失败:$_") -ForegroundColor Red
exit 1 exit 1
} finally { } finally {
Pop-Location Pop-Location
} }
} else { } else {
Write-Host " [ERROR] A compatible Node.js/npm could not be installed. Install Node.js LTS and retry." -ForegroundColor Red Write-Host (Get-LaunchText " [ERROR] A compatible Node.js/npm could not be installed. Install Node.js LTS and retry." " [错误] 无法安装兼容的 Node.js/npm。请安装 Node.js LTS 后重试。") -ForegroundColor Red
exit 1 exit 1
} }
} }
@@ -505,7 +518,7 @@ Start-LocalAdbServer
# Launch via `python -m artemis` (not the `artemis.exe` console-script shim) so the # Launch via `python -m artemis` (not the `artemis.exe` console-script shim) so the
# long-running server never locks .venv\Scripts\artemis.exe against `uv sync` reinstalls. # long-running server never locks .venv\Scripts\artemis.exe against `uv sync` reinstalls.
Write-Host " [INFO] Launching Artemis Showcase UI & Admin Console..." -ForegroundColor Green Write-Host (Get-LaunchText " [INFO] Launching Artemis Showcase UI & Admin Console..." " [启动] 正在启动 ARTEMIS 网页与管理服务...") -ForegroundColor Green
if ($NoOpen) { if ($NoOpen) {
& $ArtemisPython -m artemis ui --port $Port --no-open & $ArtemisPython -m artemis ui --port $Port --no-open
} else { } else {
+333 -107
View File
@@ -1,4 +1,4 @@
"""Check an existing checkout and refresh only already-configured local MCP clients.""" """Check an existing checkout and synchronize installed local MCP clients."""
from __future__ import annotations from __future__ import annotations
@@ -19,6 +19,10 @@ from urllib.parse import urlsplit
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
def launch_text(english: str, chinese: str) -> str:
return chinese if os.environ.get("ARTEMIS_LAUNCH_LANG") == "zh-CN" else english
def git(root: Path, *args: str, timeout: int = 15) -> str: def git(root: Path, *args: str, timeout: int = 15) -> str:
env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GCM_INTERACTIVE": "Never"} env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GCM_INTERACTIVE": "Never"}
result = subprocess.run( result = subprocess.run(
@@ -50,19 +54,28 @@ def update_source(root: Path, port: int = 8000) -> int:
or (status.get("status") == "running" and status.get("pid")) or (status.get("status") == "running" and status.get("pid"))
): ):
print( print(
"[WAIT] Tasks are active. Finish/stop them before updating dependencies or restarting." launch_text(
"[WAIT] Tasks are active. Finish/stop them before updating dependencies or restarting.",
"[等待] 有任务正在执行或排队。请先处理任务,再更新依赖或重启。",
)
) )
return 20 return 20
except (OSError, ValueError): except (OSError, ValueError):
pass pass
if not shutil.which("git"): if not shutil.which("git"):
print( print(
"[SKIP] Git is not installed. Continuing with local source; install Git for automatic source updates." launch_text(
"[SKIP] Git is not installed. Continuing with local source; install Git for automatic source updates.",
"[跳过] 未安装 Git,继续使用当前源码;安装 Git 后可自动检查更新。",
)
) )
return 0 return 0
if not (root / ".git").exists(): if not (root / ".git").exists():
print( print(
"[SKIP] This source ZIP has no Git metadata. Use a Git clone for automatic source updates; local files are preserved." launch_text(
"[SKIP] This older source ZIP has no Git metadata. Use a new release ZIP or Git clone for automatic updates; local files are preserved.",
"[跳过] 旧版源码包不含 Git 信息。请使用新版源码包或 Git 克隆;当前文件保持不变。",
)
) )
return 0 return 0
try: try:
@@ -79,41 +92,58 @@ def update_source(root: Path, port: int = 8000) -> int:
raise RuntimeError( raise RuntimeError(
"Use a clean Git remote URL and a credential manager, not embedded credentials." "Use a clean Git remote URL and a credential manager, not embedded credentials."
) )
print("[CHECK] Checking the current branch's configured upstream...") print(
launch_text(
"[CHECK] Checking the current branch's configured upstream...",
"[检查] 正在检查当前分支的远程更新...",
)
)
git(root, "fetch", "--no-tags", remote, merge_ref, timeout=45) git(root, "fetch", "--no-tags", remote, merge_ref, timeout=45)
target = git(root, "rev-parse", "FETCH_HEAD") target = git(root, "rev-parse", "FETCH_HEAD")
current = git(root, "rev-parse", "HEAD") current = git(root, "rev-parse", "HEAD")
if current == target: if current == target:
print("[OK] Source is up to date.") print(launch_text("[OK] Source is up to date.", "[完成] 源码已是最新版。"))
return 0 return 0
if git(root, "status", "--porcelain"): if git(root, "status", "--porcelain"):
print( print(
"[SKIP] Upstream differs, but local changes exist. Commit/back up your work before updating; continuing locally." launch_text(
"[SKIP] Upstream differs, but local changes exist. Commit/back up your work before updating; continuing locally.",
"[跳过] 发现本地修改;请先提交或备份。此次继续使用本地源码。",
)
) )
return 0 return 0
ancestor = git(root, "merge-base", "HEAD", target) ancestor = git(root, "merge-base", "HEAD", target)
if ancestor != current: if ancestor != current:
print( print(
"[SKIP] Local branch is ahead or diverged. No automatic merge/rebase; continuing locally." launch_text(
"[SKIP] Local branch is ahead or diverged. No automatic merge/rebase; continuing locally.",
"[跳过] 本地分支领先或分叉,不自动合并;继续使用本地源码。",
)
) )
return 0 return 0
git(root, "merge", "--ff-only", target, timeout=30) git(root, "merge", "--ff-only", target, timeout=30)
print( print(
f"[UPDATED] Source {current[:12]} -> {target[:12]}. Reloading launcher and checking dependencies." launch_text(
f"[UPDATED] Source {current[:12]} -> {target[:12]}. Reloading launcher and checking dependencies.",
f"[已更新] 源码 {current[:12]} -> {target[:12]},重新载入启动脚本并检查依赖。",
)
) )
return 10 return 10
except (RuntimeError, subprocess.TimeoutExpired) as exc: except (RuntimeError, subprocess.TimeoutExpired) as exc:
print( print(
f"[WARN] {exc if isinstance(exc, RuntimeError) else 'Git update timed out.'} Continuing with local source." launch_text(
f"[WARN] {exc if isinstance(exc, RuntimeError) else 'Git update timed out.'} Continuing with local source.",
"[提示] Git 更新失败或超时;请检查网络、凭据和仓库状态。继续使用当前源码。",
)
) )
return 0 return 0
def _atomic_write(path: Path, text: str) -> None: def _atomic_write(path: Path, text: str) -> None:
if path.read_text(encoding="utf-8") == text: if path.is_file() and path.read_text(encoding="utf-8") == text:
return return
backup = path.with_name(path.name + ".artemis-update.bak") backup = path.with_name(path.name + ".artemis-update.bak")
if not backup.exists(): if path.is_file() and not backup.exists():
shutil.copy2(path, backup) shutil.copy2(path, backup)
fd, name = tempfile.mkstemp(prefix=".artemis-update-", dir=path.parent) fd, name = tempfile.mkstemp(prefix=".artemis-update-", dir=path.parent)
try: try:
@@ -124,87 +154,120 @@ def _atomic_write(path: Path, text: str) -> None:
Path(name).unlink(missing_ok=True) Path(name).unlink(missing_ok=True)
def _belongs_to_project(config: dict, root: Path) -> bool: def _is_standard_local_artemis(config: dict) -> bool:
if config.get("url"): if config.get("url") or config.get("type") not in (None, "stdio"):
return False return False
args = config.get("args", []) args = config.get("args", [])
if args[:2] not in (["-m", "mcp_server"], ["-m", "mcp_server.server"]): # Running this launcher selects this checkout, including after a user moves
# A hand-written wrapper/uv/SSE command may need custom arguments; leave it alone. # a release ZIP to a new directory. Do not take over custom wrappers/SSE.
return False return args in (["-m", "mcp_server"], ["-m", "mcp_server.server"])
candidates = [config.get("cwd"), config.get("env", {}).get("PYTHONPATH")]
for value in candidates:
if isinstance(value, str) and Path(value).resolve() == root.resolve():
return True
command = config.get("command")
return (
isinstance(command, str)
and Path(command).resolve().parent == (root / ".venv" / "Scripts").resolve()
)
def _tool_updates(config: dict, names: tuple[str, ...]) -> dict: def _updated_entry(config: dict, template: dict, root: Path, names: tuple[str, ...]) -> dict:
updates = {} updated = {**config, "command": template["command"], "args": template["args"]}
if "enabled_tools" in config: if "cwd" in template or "cwd" in config:
updates["enabled_tools"] = list(dict.fromkeys([*config["enabled_tools"], *names])) updated["cwd"] = str(root)
if isinstance(config.get("tools"), dict): environment = config.get("env", {})
updates["tools"] = {**{name: {"eager": True} for name in names}, **config["tools"]} if not isinstance(environment, dict):
return updates raise ValueError("The existing Artemis MCP environment is not an object")
updated["env"] = {**environment, **template["env"]}
if "enabled_tools" in template or "enabled_tools" in config:
updated["enabled_tools"] = list(names)
if "tools" in template or "tools" in config:
current_tools = config.get("tools", {})
if not isinstance(current_tools, dict):
raise ValueError("The existing Artemis MCP tools setting is not an object")
updated["tools"] = {name: {"eager": True, **current_tools.get(name, {})} for name in names}
for key in ("type", "required", "startup_timeout_sec", "connectionTimeoutMs"):
if key in template and key not in updated:
updated[key] = template[key]
return updated
def refresh_json( def refresh_json(
path: Path, keys: tuple[str, ...], root: Path, python: str, names: tuple[str, ...] path: Path,
) -> bool: keys: tuple[str, ...],
if not path.is_file(): 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 return False
raw = path.read_text(encoding="utf-8") raw = path.read_text(encoding="utf-8") if path.is_file() else "{}"
data = json.loads(raw) data = json.loads(raw)
if not isinstance(data, dict):
raise ValueError("MCP configuration must be a JSON object")
parent = data parent = data
for key in keys: for key in keys:
parent = parent.get(key, {}) child = parent.get(key)
if child is None and install_missing:
child = parent[key] = {}
if not isinstance(child, dict):
raise ValueError(f"Invalid MCP configuration section: {key}")
parent = child
config = parent.get("artemis") config = parent.get("artemis")
if not isinstance(config, dict) or not _belongs_to_project(config, root): if config is None and install_missing:
return False if template is None:
updated = {**config, "command": python, **_tool_updates(config, names)} 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: if updated == config:
return False return False
parent["artemis"] = updated parent["artemis"] = updated
path.parent.mkdir(parents=True, exist_ok=True)
_atomic_write(path, json.dumps(data, ensure_ascii=False, indent=2) + "\n") _atomic_write(path, json.dumps(data, ensure_ascii=False, indent=2) + "\n")
return True return True
def refresh_codex(path: Path, root: Path, python: str, names: tuple[str, ...]) -> bool: def _toml_value(value: object) -> str:
if not path.is_file(): if isinstance(value, bool):
return False return "true" if value else "false"
raw = path.read_text(encoding="utf-8") return json.dumps(value, ensure_ascii=False)
config = tomllib.loads(raw).get("mcp_servers", {}).get("artemis")
if not isinstance(config, dict) or not _belongs_to_project(config, root):
return False def _toml_inline_table(values: dict) -> str:
wanted = {"command": python, **_tool_updates(config, names)} return "{ " + ", ".join(f"{key} = {_toml_value(value)}" for key, value in values.items()) + " }"
wanted = {key: value for key, value in wanted.items() if config.get(key) != value}
if not wanted:
return False def _replace_toml_tables(raw: str, assignments: dict[str, dict]) -> str:
"""Update only Artemis TOML tables; preserve every unrelated section and setting."""
lines = raw.splitlines(keepends=True) lines = raw.splitlines(keepends=True)
inside = False output: list[str] = []
found = set() section = ""
output = [] found: dict[str, set[str]] = {name: set() for name in assignments}
seen: set[str] = set()
def append_missing(): def append_missing() -> None:
for key, value in wanted.items(): if section in assignments:
if key not in found: for key, value in assignments[section].items():
output.append(f"{key} = {json.dumps(value, ensure_ascii=False)}\n") if key not in found[section]:
found.add(key) rendered = _toml_inline_table(value) if key == "env" else _toml_value(value)
output.append(f"{key} = {rendered}\n")
found[section].add(key)
# Replace complete assignment spans, including multiline arrays, using TOML parsing.
index = 0 index = 0
while index < len(lines): while index < len(lines):
line = lines[index] line = lines[index]
if line.lstrip().startswith("["): if line.lstrip().startswith("["):
if inside: append_missing()
append_missing()
header = re.sub(r"[\s\"']", "", line.split("#", 1)[0]).strip() header = re.sub(r"[\s\"']", "", line.split("#", 1)[0]).strip()
inside = header == "[mcp_servers.artemis]" section = header[1:-1] if header.startswith("[") and header.endswith("]") else ""
match = re.match(r"\s*(\w+)\s*=", line) if inside else None seen.add(section)
if match and match[1] in wanted: match = re.match(r"\s*(\w+)\s*=", line) if section in assignments else None
if match and match[1] in assignments[section]:
key = match[1] key = match[1]
original = line original = line
while True: while True:
@@ -216,73 +279,236 @@ def refresh_codex(path: Path, root: Path, python: str, names: tuple[str, ...]) -
if index >= len(lines): if index >= len(lines):
raise ValueError("Cannot safely parse existing MCP setting") raise ValueError("Cannot safely parse existing MCP setting")
original += lines[index] original += lines[index]
output.append(f"{key} = {json.dumps(wanted[key], ensure_ascii=False)}\n") value = assignments[section][key]
found.add(key) rendered = _toml_inline_table(value) if key == "env" else _toml_value(value)
output.append(f"{key} = {rendered}\n")
found[section].add(key)
else: else:
output.append(line) output.append(line)
index += 1 index += 1
if inside: append_missing()
append_missing() for name, values in assignments.items():
updated = "".join(output) if name not in seen:
parsed = tomllib.loads(updated) if output and not output[-1].endswith("\n"):
if any(parsed["mcp_servers"]["artemis"].get(k) != v for k, v in wanted.items()): output.append("\n")
raise ValueError("Could not locate the existing Artemis table safely") output.append(f"\n[{name}]\n")
_atomic_write(path, updated) for key, value in values.items():
return updated != raw rendered = _toml_inline_table(value) if key == "env" else _toml_value(value)
output.append(f"{key} = {rendered}\n")
return "".join(output)
def refresh_mcp(root: Path) -> None: def refresh_codex(
path: Path,
root: Path,
python: str,
names: tuple[str, ...],
template: dict | None = None,
install_missing: bool = False,
) -> bool | None:
if not path.is_file() and not install_missing:
return False
raw = path.read_text(encoding="utf-8") if path.is_file() else ""
parsed = tomllib.loads(raw)
config = parsed.get("mcp_servers", {}).get("artemis")
if config is None and install_missing:
if template is None:
raise ValueError("A standard MCP template is required for installation")
updated = template
elif isinstance(config, dict) and _is_standard_local_artemis(config):
if template is None:
template = {
"command": python,
"args": ["-m", "mcp_server"],
"cwd": str(root),
"env": {"PYTHONPATH": str(root)},
"enabled_tools": list(names),
}
updated = _updated_entry(config, template, root, names)
else:
return None
top = {
key: value
for key, value in updated.items()
if key != "env" and (not config or config.get(key) != value)
}
environment = updated["env"]
if config and config.get("env") == environment:
env_updates = {}
else:
env_updates = {
key: value
for key, value in environment.items()
if not config or config.get("env", {}).get(key) != value
}
env_header = "mcp_servers.artemis.env"
has_env_table = re.search(r"(?m)^\s*\[mcp_servers\.artemis\.env\]\s*(?:#.*)?$", raw) is not None
if config and "env" in config and not has_env_table and env_updates:
top["env"] = environment
env_updates = {}
assignments = {"mcp_servers.artemis": top}
if env_updates:
assignments[env_header] = env_updates
if not top and not env_updates:
return False
result = _replace_toml_tables(raw, assignments)
actual = tomllib.loads(result)["mcp_servers"]["artemis"]
if any(actual.get(key) != value for key, value in top.items()) or any(
actual.get("env", {}).get(key) != value for key, value in env_updates.items()
):
raise ValueError("Could not safely refresh the Artemis MCP table")
path.parent.mkdir(parents=True, exist_ok=True)
_atomic_write(path, result)
return result != raw
def refresh_mcp(root: Path) -> int:
sys.path.insert(0, str(root)) sys.path.insert(0, str(root))
from artemis.interfaces.cli.commands.mcp import ARTEMIS_MCP_TOOLS, _get_vscode_user_dir from artemis.interfaces.cli.commands.mcp import (
ARTEMIS_MCP_TOOLS,
_get_config_snippet,
_get_vscode_user_dir,
)
home = Path.home() home = Path.home()
vscode = _get_vscode_user_dir() vscode = _get_vscode_user_dir()
appdata = Path(os.environ.get("APPDATA", home / "AppData/Roaming")) appdata = Path(os.environ.get("APPDATA", home / "AppData/Roaming"))
cline_store = vscode / "globalStorage/saoudrizwan.claude-dev"
roo_store = vscode / "globalStorage/rooveterinaryinc.roo-cline"
paths = [ paths = [
(home / ".gemini/config/mcp_config.json", ("mcpServers",)), (home / ".gemini/config/mcp_config.json", ("mcpServers",), "antigravity", home / ".gemini"),
(home / ".gemini/jetski/mcp_config.json", ("mcpServers",)), (home / ".gemini/jetski/mcp_config.json", ("mcpServers",), "jetski", home / ".gemini"),
(home / ".gemini/antigravity/mcp_config.json", ("mcpServers",)),
(home / ".cursor/mcp.json", ("mcpServers",)),
(home / ".codeium/windsurf/mcp_config.json", ("mcpServers",)),
(home / ".claude.json", ("mcpServers",)),
(appdata / "Claude/claude_desktop_config.json", ("mcpServers",)),
(vscode / "mcp.json", ("servers",)),
(home / ".copilot/mcp-config.json", ("servers",)),
(home / ".cline/data/settings/cline_mcp_settings.json", ("mcpServers",)),
(home / ".cline/mcp.json", ("mcpServers",)),
( (
vscode / "globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json", home / ".gemini/antigravity/mcp_config.json",
("mcpServers",), ("mcpServers",),
"antigravity",
home / ".gemini",
),
(home / ".cursor/mcp.json", ("mcpServers",), "cursor", home / ".cursor"),
(
home / ".codeium/windsurf/mcp_config.json",
("mcpServers",),
"windsurf",
home / ".codeium/windsurf",
),
(home / ".claude.json", ("mcpServers",), "claude", home / ".claude"),
(
appdata / "Claude/claude_desktop_config.json",
("mcpServers",),
"claude",
appdata / "Claude",
),
(vscode / "mcp.json", ("servers",), "vscode", vscode),
(home / ".copilot/mcp-config.json", ("servers",), "vscode", home / ".copilot"),
(
home / ".cline/data/settings/cline_mcp_settings.json",
("mcpServers",),
"cline",
home / ".cline",
),
(home / ".cline/mcp.json", ("mcpServers",), "cline", home / ".cline"),
(
cline_store / "settings/cline_mcp_settings.json",
("mcpServers",),
"cline",
cline_store,
), ),
( (
vscode / "globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json", roo_store / "settings/mcp_settings.json",
("mcpServers",), ("mcpServers",),
"roo",
roo_store,
), ),
( (
vscode / "globalStorage/rooveterinaryinc.roo-cline/settings/cline_mcp_settings.json", roo_store / "settings/cline_mcp_settings.json",
("mcpServers",), ("mcpServers",),
"roo",
roo_store,
), ),
(root / ".roo/mcp.json", ("mcpServers",)), (root / ".roo/mcp.json", ("mcpServers",), "roo", root / ".roo"),
(home / ".openclaw/openclaw.json", ("mcp", "servers")), (home / ".openclaw/openclaw.json", ("mcp", "servers"), "openclaw", home / ".openclaw"),
] ]
changed = 0 codex_home = Path(os.environ.get("CODEX_HOME", home / ".codex"))
codex = Path(os.environ.get("CODEX_HOME", home / ".codex")) / "config.toml" paths.append((codex_home / "config.toml", (), "codex", codex_home))
for path, keys in [*paths, (codex, ())]: installed = updated_count = ready = skipped = errors = 0
for path, keys, client, marker in paths:
if not path.is_file() and not marker.is_dir():
continue
existed = path.is_file()
template = _get_config_snippet(client, sys.executable, str(root))
for key in keys:
template = template[key]
template = template["artemis"] if keys else template["mcp_servers"]["artemis"]
try: try:
updated = ( changed = (
refresh_json(path, keys, root, sys.executable, ARTEMIS_MCP_TOOLS) refresh_json(
path,
keys,
root,
sys.executable,
ARTEMIS_MCP_TOOLS,
template=template,
install_missing=True,
)
if keys if keys
else refresh_codex(path, root, sys.executable, ARTEMIS_MCP_TOOLS) else refresh_codex(
path,
root,
sys.executable,
ARTEMIS_MCP_TOOLS,
template=template,
install_missing=True,
)
) )
if updated: if changed:
print(f"[UPDATED] Existing MCP configuration: {path}") print(
changed += 1 launch_text(
f"[{'UPDATED' if existed else 'INSTALLED'}] MCP configuration: {path}",
f"[{'已更新' if existed else '已安装'}] MCP 配置:{path}",
)
)
if existed:
updated_count += 1
else:
installed += 1
elif changed is None:
print(
launch_text(
f"[SKIP] Custom Artemis MCP entry in {path}; use the manual installer for this client.",
f"[跳过] {path} 使用自定义 ARTEMIS MCP 启动方式;请手动检查此客户端。",
)
)
skipped += 1
else:
ready += 1
except (OSError, ValueError, TypeError, AttributeError): except (OSError, ValueError, TypeError, AttributeError):
print(f"[WARN] Could not safely refresh {path}; original configuration preserved.") print(
launch_text(
f"[ERROR] Could not safely synchronize {path}; original configuration preserved.",
f"[错误] 无法安全同步 {path};原配置已保留。",
)
)
errors += 1
print( print(
f"[OK] MCP source is current; refreshed {changed} existing client configuration(s). Reload the MCP connection in your AI client after a source update." 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("[TIP] For a new client run: uv run artemis mcp --install <client>") print(
launch_text(
"[ACTION] Reload or restart your AI client to load the current MCP process and tool schemas.",
"[操作] 请在 AI 客户端重新加载 MCP 或重启客户端,使新版进程和工具签名生效。",
)
)
if not (installed or updated_count or ready or skipped or errors):
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:
@@ -314,7 +540,7 @@ if __name__ == "__main__":
if args.action == "source": if args.action == "source":
sys.exit(update_source(args.root, args.port)) sys.exit(update_source(args.root, args.port))
elif args.action == "mcp": elif args.action == "mcp":
refresh_mcp(args.root) sys.exit(refresh_mcp(args.root))
else: else:
marker = args.root / "apps/showcase_ui/node_modules/.artemis-source-hash" marker = args.root / "apps/showcase_ui/node_modules/.artemis-source-hash"
current = frontend_fingerprint(args.root) current = frontend_fingerprint(args.root)
+30 -3
View File
@@ -1,6 +1,33 @@
@echo off @echo off
rem Use China mirrors for first setup and subsequent launches. chcp 65001 >nul 2>&1
rem All start.bat arguments, including -SetupOnly, are supported.
setlocal DisableDelayedExpansion setlocal DisableDelayedExpansion
set "ARTEMIS_LAUNCH_LANG=zh-CN"
title ARTEMIS 国内镜像启动
if /i "%~1"=="-Help" goto help
if /i "%~1"=="--help" goto help
echo ======================================================
echo ARTEMIS 中文启动 - 使用国内镜像
echo ======================================================
echo 正在检查已有环境、项目更新及依赖;首次启动可能需要下载组件。
echo 镜像地址和下载超时可在 config\downloads.json 中调整。
echo.
call "%~dp0start.bat" -Mirror China %* call "%~dp0start.bat" -Mirror China %*
exit /b %ERRORLEVEL% set "ARTEMIS_CN_EXIT_CODE=%ERRORLEVEL%"
if not "%ARTEMIS_CN_EXIT_CODE%"=="0" echo 启动未完成;请查看上方错误信息,并检查网络或镜像设置。
endlocal & exit /b %ARTEMIS_CN_EXIT_CODE%
:help
echo 用法:start-cn.bat [选项]
echo.
echo -SetupOnly 安装或检查环境,不启动网页
echo -Port 8001 使用指定端口,默认 8000
echo -NoOpen 启动服务后不自动打开浏览器
echo -SkipUpdate 跳过 Git 源码更新检查
echo -SkipMcpSync 仅启动网页,跳过 MCP 安装和更新
echo -Help 显示本说明
echo.
echo 国内镜像设置:config\downloads.json
echo 首次运行需联网;后续运行会检查源码、依赖,并同步已安装 AI 客户端的 MCP。
endlocal & exit /b 0
+3 -3
View File
@@ -15,12 +15,12 @@ rem limitations under the License.
chcp 65001 >nul 2>&1 chcp 65001 >nul 2>&1
setlocal DisableDelayedExpansion setlocal DisableDelayedExpansion
title Artemis Mobile Agent UI if /i "%ARTEMIS_LAUNCH_LANG%"=="zh-CN" (title ARTEMIS 手机自动化测试) else (title Artemis Mobile Agent UI)
pushd "%~dp0" || exit /b 1 pushd "%~dp0" || exit /b 1
echo ====================================================== echo ======================================================
echo Artemis Autonomous Mobile Agent UI if /i "%ARTEMIS_LAUNCH_LANG%"=="zh-CN" (echo ARTEMIS 手机自动化测试) else (echo Artemis Autonomous Mobile Agent UI)
echo ====================================================== echo ======================================================
echo. echo.
@@ -33,7 +33,7 @@ set "ARTEMIS_EXIT_CODE=%ERRORLEVEL%"
if not "%ARTEMIS_EXIT_CODE%"=="0" ( if not "%ARTEMIS_EXIT_CODE%"=="0" (
echo. echo.
echo An error occurred while starting Artemis. if /i "%ARTEMIS_LAUNCH_LANG%"=="zh-CN" (echo ARTEMIS 启动失败,请查看上方详细信息。) else (echo An error occurred while starting Artemis.)
if not defined ARTEMIS_NO_PAUSE pause if not defined ARTEMIS_NO_PAUSE pause
) )
popd popd
@@ -59,6 +59,17 @@ def test_save_reload_and_blank_key_preserves_secret(isolated_settings):
assert dotenv_values(isolated_settings)["OPENAI_API_KEY"] == "new-test-secret" assert dotenv_values(isolated_settings)["OPENAI_API_KEY"] == "new-test-secret"
def test_new_install_uses_playones_endpoint_without_overriding_saved_url(monkeypatch):
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
expected = "https://api-n-cd.playones.com/v1"
assert Settings(_env_file=None).OPENAI_BASE_URL == expected
assert service.ModelSettingsUpdate(model="test").base_url == expected
monkeypatch.setattr(settings, "OPENAI_BASE_URL", None)
assert service.get_model_settings()["base_url"] == expected
monkeypatch.setattr(settings, "OPENAI_BASE_URL", "https://saved.example/v1")
assert service.get_model_settings()["base_url"] == "https://saved.example/v1"
@pytest.mark.parametrize( @pytest.mark.parametrize(
"url", "url",
[ [
+8 -15
View File
@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""Keep bootstrap scripts ASCII for Windows PowerShell 5.1 compatibility. """Require ASCII or UTF-8 with BOM for Windows PowerShell 5.1 scripts.
BOM-less scripts are decoded using the system ANSI code page. UTF-8 symbols BOM-less scripts are decoded using the system ANSI code page. UTF-8 symbols
can become quote delimiters under cp1252 and cause parse errors. can become quote delimiters under cp1252 and cause parse errors.
@@ -31,18 +31,11 @@ def test_powershell_scripts_are_discovered():
@pytest.mark.parametrize("script", POWERSHELL_SCRIPTS, ids=lambda p: p.name) @pytest.mark.parametrize("script", POWERSHELL_SCRIPTS, ids=lambda p: p.name)
def test_powershell_script_is_pure_ascii(script): def test_powershell_script_has_windows_compatible_encoding(script):
raw = script.read_bytes() raw = script.read_bytes()
offending = [] if any(byte > 0x7F for byte in raw):
line_no = 1 assert raw.startswith(b"\xef\xbb\xbf"), (
for index, byte in enumerate(raw): f"{script.name} contains non-ASCII bytes without a UTF-8 BOM; "
if byte == 0x0A: "Windows PowerShell 5.1 would use the system ANSI code page."
line_no += 1 )
elif byte > 0x7F: raw.decode("utf-8-sig")
offending.append((line_no, index, hex(byte)))
assert not offending, (
f"{script.name} contains non-ASCII bytes at (line, offset, byte) "
f"{offending[:10]}; Windows PowerShell 5.1 decodes BOM-less .ps1 files "
"with the system ANSI code page. Use ASCII status labels."
)
+157 -6
View File
@@ -120,7 +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_only_updates_existing_matching_project(tmp_path): def test_json_migrates_existing_standard_mcp_to_current_checkout(tmp_path):
path = tmp_path / "mcp.json" path = tmp_path / "mcp.json"
root = tmp_path / "project" root = tmp_path / "project"
config = { config = {
@@ -132,19 +132,170 @@ def test_json_only_updates_existing_matching_project(tmp_path):
"autoApprove": [], "autoApprove": [],
} }
path.write_text(json.dumps({"mcpServers": {"artemis": config, "other": {"command": "keep"}}})) path.write_text(json.dumps({"mcpServers": {"artemis": config, "other": {"command": "keep"}}}))
assert not update.refresh_json( moved_root = tmp_path / "new release"
path, ("mcpServers",), tmp_path / "different", "new", ("mobile_batch",) assert update.refresh_json(path, ("mcpServers",), moved_root, "new", ("mobile_batch",))
)
assert update.refresh_json(path, ("mcpServers",), root, "new", ("mobile_batch",))
result = json.loads(path.read_text())["mcpServers"] result = json.loads(path.read_text())["mcpServers"]
assert result["artemis"]["env"] == {"CUSTOM": "preserved"} assert result["artemis"]["env"]["CUSTOM"] == "preserved"
assert result["artemis"]["env"]["PYTHONPATH"] == str(moved_root)
assert result["artemis"]["cwd"] == str(moved_root)
assert result["artemis"]["disabled"] is True assert result["artemis"]["disabled"] is True
assert result["other"] == {"command": "keep"} assert result["other"] == {"command": "keep"}
assert not update.refresh_json(path, ("mcpServers",), moved_root, "new", ("mobile_batch",))
missing = tmp_path / "not-installed.json" missing = tmp_path / "not-installed.json"
assert not update.refresh_json(missing, ("mcpServers",), root, "new", ()) assert not update.refresh_json(missing, ("mcpServers",), root, "new", ())
assert not missing.exists() assert not missing.exists()
def test_mcp_sync_installs_detected_clients_and_migrates_codex(tmp_path, monkeypatch):
from artemis.interfaces.cli.commands import mcp as mcp_command
home = tmp_path / "home"
home.mkdir()
codex_home = home / ".codex"
codex_home.mkdir()
codex_config = codex_home / "config.toml"
codex_config.write_text(
'model = "my-model"\n'
"[mcp_servers.artemis]\n"
'command = "old-python"\n'
'args = ["-m", "mcp_server.server"]\n'
'cwd = "C:/old-release"\n'
'enabled_tools = ["mobile_run_task", "retired_tool"]\n'
"enabled = false\n"
"[mcp_servers.artemis.env]\n"
'PYTHONPATH = "C:/old-release"\n'
'OPENAI_API_KEY = "keep-secret"\n',
encoding="utf-8",
)
cursor_home = home / ".cursor"
cursor_home.mkdir()
(cursor_home / "mcp.json").write_text('{"mcpServers":{"other":{"command":"keep"}}}')
monkeypatch.setattr("pathlib.Path.home", lambda: home)
monkeypatch.setattr(mcp_command, "_get_vscode_user_dir", lambda: home / "vscode-user")
monkeypatch.setenv("CODEX_HOME", str(codex_home))
monkeypatch.setenv("APPDATA", str(home / "AppData"))
root = tmp_path / "new release"
assert update.refresh_mcp(root) == 0
codex = tomllib.loads(codex_config.read_text(encoding="utf-8"))
entry = codex["mcp_servers"]["artemis"]
assert codex["model"] == "my-model"
assert entry["command"] == update.sys.executable
assert entry["cwd"] == str(root)
assert entry["args"] == ["-m", "mcp_server"]
assert entry["enabled"] is False
assert entry["env"]["OPENAI_API_KEY"] == "keep-secret"
assert entry["env"]["PYTHONPATH"] == str(root)
assert entry["enabled_tools"] == list(mcp_command.ARTEMIS_MCP_TOOLS)
cursor = json.loads((cursor_home / "mcp.json").read_text(encoding="utf-8"))
assert cursor["mcpServers"]["artemis"]["env"]["PYTHONPATH"] == str(root)
assert cursor["mcpServers"]["other"] == {"command": "keep"}
first = codex_config.read_text(encoding="utf-8")
assert update.refresh_mcp(root) == 0
assert codex_config.read_text(encoding="utf-8") == first
def test_mcp_sync_fails_on_invalid_detected_config_without_overwriting(tmp_path, monkeypatch):
from artemis.interfaces.cli.commands import mcp as mcp_command
home = tmp_path / "home"
cursor_home = home / ".cursor"
cursor_home.mkdir(parents=True)
config = cursor_home / "mcp.json"
config.write_text("{invalid json", encoding="utf-8")
monkeypatch.setattr("pathlib.Path.home", lambda: home)
monkeypatch.setattr(mcp_command, "_get_vscode_user_dir", lambda: home / "vscode-user")
monkeypatch.delenv("CODEX_HOME", raising=False)
assert update.refresh_mcp(tmp_path / "project") == 1
assert config.read_text(encoding="utf-8") == "{invalid json"
def test_mcp_sync_installs_codex_when_client_directory_exists(tmp_path, monkeypatch):
from artemis.interfaces.cli.commands import mcp as mcp_command
home = tmp_path / "home"
codex_home = home / ".codex"
codex_home.mkdir(parents=True)
monkeypatch.setattr("pathlib.Path.home", lambda: home)
monkeypatch.setattr(mcp_command, "_get_vscode_user_dir", lambda: home / "vscode-user")
monkeypatch.setenv("CODEX_HOME", str(codex_home))
root = tmp_path / "project"
assert update.refresh_mcp(root) == 0
config = tomllib.loads((codex_home / "config.toml").read_text(encoding="utf-8"))
assert config["mcp_servers"]["artemis"]["cwd"] == str(root)
assert config["mcp_servers"]["artemis"]["enabled_tools"] == list(mcp_command.ARTEMIS_MCP_TOOLS)
def test_codex_install_preserves_existing_user_tables(tmp_path):
path = tmp_path / "config.toml"
path.write_text(
'model = "keep"\n[mcp_servers.other]\ncommand = "other-tool"\n', encoding="utf-8"
)
template = {
"command": "new-python",
"args": ["-m", "mcp_server"],
"cwd": str(tmp_path),
"env": {"PYTHONPATH": str(tmp_path)},
"enabled_tools": ["mobile_run_task"],
}
assert update.refresh_codex(
path,
tmp_path,
"new-python",
("mobile_run_task",),
template=template,
install_missing=True,
)
data = tomllib.loads(path.read_text(encoding="utf-8"))
assert data["model"] == "keep"
assert data["mcp_servers"]["other"]["command"] == "other-tool"
assert data["mcp_servers"]["artemis"]["env"]["PYTHONPATH"] == str(tmp_path)
def test_codex_inline_environment_sync_is_idempotent(tmp_path):
path = tmp_path / "config.toml"
path.write_text(
'[mcp_servers.artemis]\ncommand = "old-python"\n'
'args = ["-m", "mcp_server"]\n'
'env = { PYTHONPATH = "C:/old", OPENAI_API_KEY = "keep-secret" }\n',
encoding="utf-8",
)
template = {
"command": "new-python",
"args": ["-m", "mcp_server"],
"cwd": str(tmp_path),
"env": {"PYTHONPATH": str(tmp_path)},
"enabled_tools": ["mobile_run_task"],
}
assert update.refresh_codex(
path, tmp_path, "new-python", ("mobile_run_task",), template=template
)
data = tomllib.loads(path.read_text(encoding="utf-8"))["mcp_servers"]["artemis"]
assert data["env"]["OPENAI_API_KEY"] == "keep-secret"
assert data["env"]["PYTHONPATH"] == str(tmp_path)
assert not update.refresh_codex(
path, tmp_path, "new-python", ("mobile_run_task",), template=template
)
def test_custom_mcp_wrapper_is_left_unchanged(tmp_path):
path = tmp_path / "mcp.json"
original = '{"mcpServers":{"artemis":{"command":"uv","args":["run","custom"]}}}'
path.write_text(original, encoding="utf-8")
assert (
update.refresh_json(
path,
("mcpServers",),
tmp_path,
"new-python",
("mobile_run_task",),
install_missing=True,
template={"command": "new-python", "args": ["-m", "mcp_server"], "env": {}},
)
is None
)
assert path.read_text(encoding="utf-8") == original
def test_frontend_fingerprint_detects_lock_source_and_deleted_assets(tmp_path): 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)
+112 -14
View File
@@ -1,36 +1,134 @@
import importlib.util import importlib.util
from pathlib import Path from pathlib import Path
import shutil
import subprocess
import zipfile import zipfile
import pytest
def test_archive_keeps_source_and_excludes_local_builds_and_secrets(tmp_path):
script = Path(__file__).resolve().parents[2] / "scripts/package_source.py" SCRIPT = Path(__file__).resolve().parents[2] / "scripts/package_source.py"
spec = importlib.util.spec_from_file_location("package_source", script) spec = importlib.util.spec_from_file_location("package_source", SCRIPT)
module = importlib.util.module_from_spec(spec) module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) spec.loader.exec_module(module)
def git(root, *args):
result = subprocess.run(
["git", "-C", str(root), *args], capture_output=True, text=True, timeout=30
)
assert result.returncode == 0, result.stderr
return result.stdout.strip()
@pytest.fixture
def repository(tmp_path):
if not shutil.which("git"):
pytest.skip("Git required")
root = tmp_path / "source"
root.mkdir()
git(root, "init", "-b", "main")
git(root, "config", "user.name", "Fixture")
git(root, "config", "user.email", "fixture@example.test")
keep = ["start.bat", ".env.example", "uv.lock", "apps/showcase_ui/src/main.ts"] keep = ["start.bat", ".env.example", "uv.lock", "apps/showcase_ui/src/main.ts"]
tracked_builds = [
"artemis/resources/showcase_ui/main.js",
"packages/artemis-accessibility-helper/ArtemisAccessibilityHelper.apk",
"packages/artemis-accessibility-helper/debug.keystore",
]
omit = [ omit = [
".env", ".env",
".env.local", ".env.local",
".venv/pyvenv.cfg", ".venv/pyvenv.cfg",
"nested/node_modules/foo.js", "nested/node_modules/foo.js",
"apps/showcase_ui/dist/main.js", "apps/showcase_ui/dist/main.js",
"artemis/resources/showcase_ui/main.js",
"__pycache__/a.pyc", "__pycache__/a.pyc",
"traces/data.db", "traces/data.db",
"helper.apk",
"debug.keystore",
".git/config",
".roo/mcp.json", ".roo/mcp.json",
] ]
for name in keep + omit: for name in keep + tracked_builds + omit:
target = tmp_path / name target = root / name
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
target.write_text("fixture") target.write_text("fixture")
(root / ".gitignore").write_text(
".env*\n!.env.example\n.venv/\nnode_modules/\ndist/\ntraces/\n.roo/\n__pycache__/\n"
)
git(root, "add", "--", ".gitignore", *keep, *tracked_builds)
git(root, "commit", "-m", "initial")
git(root, "remote", "add", "eapil", "https://git.example.invalid/team/project.git")
git(root, "config", "branch.main.remote", "eapil")
git(root, "config", "branch.main.merge", "refs/heads/main")
return root, keep, tracked_builds, omit
def test_archive_is_updatable_clean_checkout_without_local_data(repository, tmp_path):
root, keep, tracked_builds, omit = repository
output = tmp_path / "artifacts/source.zip" output = tmp_path / "artifacts/source.zip"
count, digest = module.package_source(tmp_path, output) count, digest = module.package_source(root, output)
assert count == len(keep) assert count > len(keep)
assert len(digest) == 64 assert len(digest) == 64
with zipfile.ZipFile(output) as archive: with zipfile.ZipFile(output) as archive:
assert archive.testzip() is None assert archive.testzip() is None
assert set(archive.namelist()) == {f"artemis/{name}" for name in keep} names = set(archive.namelist())
assert {f"artemis/{name}" for name in keep}.issubset(names)
assert not {f"artemis/{name}" for name in tracked_builds + omit} & names
assert "artemis/.git/config" in names
assert "artemis/.git/index" in names
assert not any(name.startswith("artemis/.git/logs/") for name in names)
assert not any(name.startswith("artemis/.git/hooks/") for name in names)
archive.extractall(tmp_path / "unpacked")
extracted = tmp_path / "unpacked/artemis"
assert git(extracted, "symbolic-ref", "--short", "HEAD") == "main"
assert git(extracted, "rev-parse", "HEAD") == git(root, "rev-parse", "HEAD")
assert git(extracted, "status", "--porcelain") == ""
assert git(extracted, "remote", "get-url", "origin") == (
"https://git.example.invalid/team/project.git"
)
assert git(extracted, "config", "--get", "branch.main.remote") == "origin"
(extracted / tracked_builds[0]).parent.mkdir(parents=True)
(extracted / tracked_builds[0]).write_text("new bundle")
assert git(extracted, "status", "--porcelain") == ""
def test_unpacked_archive_fast_forwards(repository, tmp_path):
root, _, tracked_builds, _ = repository
bare = tmp_path / "upstream.git"
bare.mkdir()
git(bare, "init", "--bare")
git(root, "remote", "add", "test-upstream", str(bare))
git(root, "push", "test-upstream", "main")
archive = tmp_path / "source.zip"
module.package_source(root, archive)
with zipfile.ZipFile(archive) as contents:
contents.extractall(tmp_path / "unpacked")
extracted = tmp_path / "unpacked/artemis"
git(extracted, "remote", "set-url", "origin", str(bare))
built_asset = extracted / tracked_builds[0]
built_asset.parent.mkdir(parents=True)
built_asset.write_text("generated after setup")
(root / "start.bat").write_text("updated")
git(root, "add", "--", "start.bat")
git(root, "commit", "-m", "update launcher")
git(root, "push", "test-upstream", "main")
from scripts.update_installation import update_source
assert update_source(extracted, port=1) == 10
assert (extracted / "start.bat").read_text() == "updated"
assert git(extracted, "status", "--porcelain") == ""
def test_packaging_rejects_uncommitted_changes_and_credentials(repository, tmp_path):
root, _, _, _ = repository
(root / "start.bat").write_text("local change")
with pytest.raises(ValueError, match="Commit tracked changes"):
module.package_source(root, tmp_path / "source.zip")
(root / "start.bat").write_text("fixture")
git(
root,
"remote",
"set-url",
"eapil",
"https://user:secret@git.example.invalid/team/project.git",
)
with pytest.raises(ValueError, match="credential-free HTTPS"):
module.package_source(root, tmp_path / "source.zip")
Generated
+1 -1
View File
@@ -206,7 +206,7 @@ wheels = [
[[package]] [[package]]
name = "artemis" name = "artemis"
version = "1.1.0" version = "1.1.2"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "adbutils" }, { name = "adbutils" },