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_API_KEY=
# 接口根地址:通常以 /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
# 单次模型 HTTP 请求超时(秒)
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
@@ -24,14 +24,15 @@ cd Artemis-Eapil
.\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
.\start-cn.bat -SetupOnly # 仅准备 Python 环境并检查已有 MCP 配置
.\start-cn.bat -SetupOnly # 准备环境并安装/更新已检测客户端的 MCP
.\start.bat -Port 8001 # 指定端口
.\start.bat -SkipUpdate # 跳过 Git 源码更新检查
.\start.bat -SkipMcpSync # MCP 配置异常时,仅启动网页
```
镜像与下载超时在 [config/downloads.json](config/downloads.json) 中设置。国内脚本使用山东大学镜像安装 scrcpy,并校验官方 SHA256;也可预先安装或自定义 HTTPS 下载地址。
@@ -49,19 +50,19 @@ cd Artemis-Eapil
预设模型为 `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 使用
环境与模型配置完成后,例如为 Codex 接入 MCP:
Windows 启动脚本会为已检测到的 AI 客户端安装或更新 MCP。未被检测到的客户端,可手动安装,例如 Codex:
```sh
uv run artemis mcp --install codex
```
在客户端重新加载 MCP,并按 [AI 接入指南](docs/ai-automation.md) 安装仓库中的 [artemis-automation Skill](skills/artemis-automation/SKILL.md)。Skill 不会随启动脚本自动安装到用户全局目录。其他客户端可通过 `uv run artemis mcp --help` 查看接入方式。
安装或更新后,在客户端重新加载 MCP 或重启客户端,并按 [AI 接入指南](docs/ai-automation.md) 安装仓库中的 [artemis-automation Skill](skills/artemis-automation/SKILL.md)。Skill 不会随启动脚本自动安装到用户全局目录。其他客户端可通过 `uv run artemis mcp --help` 查看接入方式。
示例指令:
@@ -69,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)
@@ -16,7 +16,7 @@ _write_lock = threading.Lock()
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)
api_key: SecretStr | None = None # Omitted/blank keeps the existing key.
timeout_seconds: float = Field(default=300, gt=0, le=3600)
@@ -41,7 +41,7 @@ class ModelSettingsUpdate(BaseModel):
):
raise ValueError("请输入有效的 HTTP(S) 接口根地址,不要包含密钥、查询参数或片段")
if value.endswith(("/responses", "/chat/completions")):
raise ValueError("请输入接口根地址,例如 https://api.openai.com/v1")
raise ValueError("请输入接口根地址,例如 https://api-n-cd.playones.com/v1")
return value
@field_validator("model")
@@ -57,7 +57,7 @@ def get_model_settings() -> dict:
return {
"provider": "openai",
"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,
"api_key_configured": bool(settings.OPENAI_API_KEY),
"timeout_seconds": settings.OPENAI_TIMEOUT_SECONDS,
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "artemis-showcase-ui",
"version": "1.1.0",
"version": "1.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "artemis-showcase-ui",
"version": "1.1.0",
"version": "1.1.2",
"dependencies": {
"@angular/cdk": "^22.1.5",
"@angular/common": "^22.1.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "artemis-showcase-ui",
"version": "1.1.0",
"version": "1.1.2",
"scripts": {
"ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json",
@@ -24,7 +24,7 @@ interface ModelSettings {
<p>统一使用 OpenAI Responses。保存后新任务立即生效,重启后自动加载。</p>
<label for="model-base-url">接口地址</label>
<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>
<label for="model-name">模型名称</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
# 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_TIMEOUT_SECONDS: float = Field(default=300.0, gt=0)
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}"
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(
f"{base}/responses",
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 目录。
先完成项目环境和模型配置,然后在项目目录执行 `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 这样下达任务:
@@ -32,11 +32,11 @@
新增工具通过统一后台服务运行,与网页共享配置、队列、历史和报告;不另外启动独立调度器。`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` 和构建,成功后才记录指纹。国内镜像选项继续生效。检测到正在执行/排队的任务时,自动更新启动会停止,让原任务继续。
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)。
+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]
name = "artemis"
version = "1.1.0"
version = "1.1.2"
description = "Autonomous AI Assistant for Mobile Automation"
authors = [
+27 -21
View File
@@ -1,42 +1,48 @@
# Python bootstrap shared by the Windows launcher and its isolated checks.
# Keep this file ASCII for Windows PowerShell 5.1.
# Python bootstrap shared by the Windows launcher and its isolated checks.
# 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 {
param([Parameter(Mandatory=$true)][string]$ProjectRoot)
$ProjectRoot = (Resolve-Path -LiteralPath $ProjectRoot -ErrorAction Stop).Path
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'))) {
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 ($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
} 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')
try {
$installerUrl = if ($ArtemisDownloads) { $ArtemisDownloads.uv_installer_url } else { 'https://astral.sh/uv/install.ps1' }
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
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 {
Remove-Item -LiteralPath $installer -Force -ErrorAction SilentlyContinue
}
}
Update-EnvironmentPath
} 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
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'
$pythonExe = Join-Path $venvDir 'Scripts\python.exe'
@@ -45,18 +51,18 @@ function Initialize-ArtemisPython {
Push-Location -LiteralPath $ProjectRoot
try {
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
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 {
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)) {
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
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.
@@ -64,19 +70,19 @@ function Initialize-ArtemisPython {
$lockOption = if ($ArtemisDownloads -and $ArtemisDownloads.python_index) { '--frozen' } else { '--locked' }
& uv sync --check $lockOption --no-dev --inexact --quiet 2>$null | Out-Host
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 {
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) {
Install-ArtemisLockedDependencies -PythonExe $pythonExe -IndexUrl $ArtemisDownloads.python_index
} else {
& 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
if ($LASTEXITCODE -ne 0) { throw 'Python dependency verification failed. The UI will not be started.' }
Write-Host ' [OK] Python environment is ready.' -ForegroundColor Green
if ($LASTEXITCODE -ne 0) { throw (Get-BootstrapText 'Python dependency verification failed. The UI will not be started.' 'Python 依赖验证失败,网页不会启动。') }
Write-Host (Get-BootstrapText ' [OK] Python environment is ready.' ' [完成] Python 环境已就绪。') -ForegroundColor Green
return $pythonExe
} finally {
Pop-Location
+7 -3
View File
@@ -1,5 +1,5 @@
# Download settings are process-local; never edit global pip/npm/uv settings.
# Keep this file ASCII for Windows PowerShell 5.1.
# Download settings are process-local; never edit global pip/npm/uv settings.
# Save as UTF-8 with BOM so Windows PowerShell 5.1 reads Chinese messages correctly.
function Initialize-ArtemisDownloads {
param([string]$ProjectRoot, [string]$Mode)
@@ -42,7 +42,11 @@ function Initialize-ArtemisDownloads {
# Official scrcpy v4.1 win64 release digest. Verify every mirror/override equally.
$values.scrcpy_sha256 = '5b12172b3264b2889f4583ee64752ce832e29bc8b1089dca81093459697165db'
$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 {
+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
from datetime import datetime
import hashlib
import os
from pathlib import Path
import subprocess
import tempfile
from urllib.parse import urlsplit
import zipfile
@@ -83,6 +86,9 @@ EXCLUDED_SUFFIXES = {
".swo",
}
GIT_METADATA_FILES = {"HEAD", "config", "index", "shallow", "packed-refs"}
GIT_METADATA_DIRS = {"objects", "refs"}
def excluded(path: Path) -> bool:
parts = tuple(part.lower() for part in path.parts)
@@ -116,22 +122,116 @@ def source_files(root: 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]:
root = root.resolve()
output = output.resolve()
branch, remote_url = release_remote(root)
revision = git(root, "rev-parse", "HEAD")
output.parent.mkdir(parents=True, exist_ok=True)
temporary = output.with_suffix(".zip.tmp")
files = [p for p in source_files(root) if p.resolve() not in {output, temporary}]
try:
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
for path in files:
archive.write(path, Path("artemis") / path.relative_to(root))
with zipfile.ZipFile(temporary) as archive:
bad_file = archive.testzip()
if bad_file:
raise RuntimeError(f"ZIP integrity check failed: {bad_file}")
if any(excluded(Path(name).relative_to("artemis")) for name in archive.namelist()):
raise RuntimeError("An excluded file was found in the source archive")
with tempfile.TemporaryDirectory(prefix="artemis-release-") as work:
checkout = Path(work) / "artemis"
git(
root,
"clone",
"--depth",
"1",
"--single-branch",
"--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)
finally:
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");
# you may not use this file except in compliance with the License.
@@ -18,12 +18,19 @@ param(
[switch]$NoOpen = $false,
[switch]$SetupOnly = $false,
[switch]$SkipUpdate = $false,
[switch]$SkipMcpSync = $false,
[ValidateSet('Official', 'China')][string]$Mirror
)
$ErrorActionPreference = "Continue"
[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)
try {
[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')
if ($NoOpen) { $reloadArgs += '-NoOpen' }
if ($SetupOnly) { $reloadArgs += '-SetupOnly' }
if ($SkipMcpSync) { $reloadArgs += '-SkipMcpSync' }
if ($Mirror) { $reloadArgs += @('-Mirror', $Mirror) }
& powershell.exe @reloadArgs
exit $LASTEXITCODE
}
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
}
}
@@ -58,12 +66,12 @@ if ((-not $SkipUpdate) -and (Test-Path -LiteralPath $existingPython)) {
try {
Initialize-ArtemisDownloads -ProjectRoot $RootDir -Mode $Mirror
} catch {
Write-Host " [ERROR] Invalid download settings: $_" -ForegroundColor Red
Write-Host (Get-LaunchText " [ERROR] Invalid download settings: $_" " [错误] 下载配置无效:$_") -ForegroundColor Red
exit 1
}
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 ""
@@ -181,7 +189,7 @@ function Install-PortablePlatformTools {
$env:PATH = "$ptDir;$env:PATH"
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 {
$zipPath = "$env:TEMP\platform-tools-windows.zip"
if (Invoke-DownloadFile -Uri $ArtemisDownloads.adb_url -OutFile $zipPath) {
@@ -190,12 +198,12 @@ function Install-PortablePlatformTools {
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
if (Test-Path "$ptDir\adb.exe") {
$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
}
}
} 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
}
@@ -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/')) {
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
}
try {
@@ -244,12 +252,12 @@ function Install-PortableScrcpy {
Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue
if (Test-Path "$scrcpyDir\scrcpy.exe") {
$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
}
}
} 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
}
@@ -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 {
$nodeVer = "v22.23.2"
$arch = if ([System.Environment]::Is64BitOperatingSystem) {
@@ -319,12 +327,12 @@ function Install-PortableNode {
Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue
if (Test-Path "$nodeDir\node.exe") {
$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
}
}
} 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
}
@@ -344,16 +352,21 @@ try {
if (-not (Test-Path -LiteralPath (Join-Path $RootDir '.env'))) {
if (Test-Path -LiteralPath (Join-Path $RootDir '.env.example')) {
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
& $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') mcp
if ($LASTEXITCODE -ne 0) {
Write-Host ' [WARN] MCP refresh failed. Existing client settings were not replaced; check the errors above.' -ForegroundColor Yellow
if (-not $SkipMcpSync) {
Write-Host (Get-LaunchText ' [CHECK] Installing/updating MCP for detected AI clients...' ' [检查] 正在为检测到的 AI 客户端安装或更新 MCP...') -ForegroundColor Cyan
& $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') mcp
if ($LASTEXITCODE -ne 0) {
Write-Host (Get-LaunchText ' [ERROR] MCP synchronization failed. Fix the configuration above, or use -SkipMcpSync to run the web UI without MCP.' ' [错误] MCP 同步失败。请修复上方配置;仅需启动网页时可使用 -SkipMcpSync。') -ForegroundColor Red
exit 1
}
} else {
Write-Host (Get-LaunchText ' [SKIP] MCP synchronization was skipped.' ' [跳过] 已跳过 MCP 同步。') -ForegroundColor Yellow
}
if ($SetupOnly) {
Write-Host ' [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
}
@@ -371,8 +384,8 @@ if ($missingCore.Count -gt 0) {
$useWinGet = $false
if ($ArtemisDownloads.mode -ne 'China' -and (Test-CommandExists "winget")) {
if ([Console]::IsInputRedirected -eq $false) {
Write-Host " [INFO] Missing components detected: $($missingCore -join ', ')." -ForegroundColor Cyan
$ans = Read-Host " Install system packages via WinGet? [Y/n]"
Write-Host (Get-LaunchText " [INFO] Missing components detected: $($missingCore -join ', ')." " [检查] 缺少组件:$($missingCore -join ', ')。") -ForegroundColor Cyan
$ans = Read-Host (Get-LaunchText " Install system packages via WinGet? [Y/n]" " 通过 WinGet 安装?[Y/n]")
if ($ans -eq "" -or $ans -match "^[Yy]") {
$useWinGet = $true
}
@@ -382,7 +395,7 @@ if ($missingCore.Count -gt 0) {
}
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") {
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 (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"
& $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') frontend-check
$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 (-not (Test-NodeCompatible)) {
if (Test-CommandExists "node") {
$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 {
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
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 use 22.23.2 | Out-Null
Update-EnvironmentPath
@@ -438,8 +451,8 @@ if ($frontendChanged -or ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Pat
$useWinGetNode = $false
if ($ArtemisDownloads.mode -ne 'China' -and (Test-CommandExists "winget")) {
if ([Console]::IsInputRedirected -eq $false) {
Write-Host " [INFO] Node.js >= v22.22.0 required for Showcase UI." -ForegroundColor Cyan
$ans = Read-Host " Install/Upgrade Node.js via WinGet (may require Administrator)? [Y/n]"
Write-Host (Get-LaunchText " [INFO] Node.js >= v22.22.0 required for Showcase UI." " [检查] 网页需要 Node.js v22.22.0 或更新版本。") -ForegroundColor Cyan
$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]") {
$useWinGetNode = $true
}
@@ -449,7 +462,7 @@ if ($frontendChanged -or ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Pat
}
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
Update-EnvironmentPath
}
@@ -463,36 +476,36 @@ if ($frontendChanged -or ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Pat
if (Test-NodeCompatible) {
$nodeVer = (& node -v 2>$null)
Write-Host " [OK] Node.js $nodeVer is ready." -ForegroundColor Green
Write-Host " [INFO] Showcase UI sources/dependencies changed or build missing. Compiling..." -ForegroundColor Yellow
Write-Host (Get-LaunchText " [OK] Node.js $nodeVer is ready." " [完成] Node.js $nodeVer 已就绪。") -ForegroundColor Green
Write-Host (Get-LaunchText " [INFO] Showcase UI sources/dependencies changed or build missing. Compiling..." " [构建] 前端源码/依赖有变化或缺少构建结果,准备重新构建...") -ForegroundColor Yellow
Push-Location "$RootDir\apps\showcase_ui"
try {
$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
if ($LASTEXITCODE -ne 0) {
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
if ($LASTEXITCODE -ne 0) { throw "Frontend build failed (exit $LASTEXITCODE)." }
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)) {
Write-Host " [OK] Showcase UI compiled successfully." -ForegroundColor Green
Write-Host (Get-LaunchText " [OK] Showcase UI compiled successfully." " [完成] 网页构建成功。") -ForegroundColor Green
} else {
throw 'Frontend build did not produce index.html.'
}
& $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') frontend-mark
if ($LASTEXITCODE -ne 0) { throw 'Could not save frontend build fingerprint.' }
} 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
} finally {
Pop-Location
}
} 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
}
}
@@ -505,7 +518,7 @@ Start-LocalAdbServer
# 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.
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) {
& $ArtemisPython -m artemis ui --port $Port --no-open
} 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
@@ -19,6 +19,10 @@ from urllib.parse import urlsplit
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:
env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GCM_INTERACTIVE": "Never"}
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"))
):
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
except (OSError, ValueError):
pass
if not shutil.which("git"):
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
if not (root / ".git").exists():
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
try:
@@ -79,41 +92,58 @@ def update_source(root: Path, port: int = 8000) -> int:
raise RuntimeError(
"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)
target = git(root, "rev-parse", "FETCH_HEAD")
current = git(root, "rev-parse", "HEAD")
if current == target:
print("[OK] Source is up to date.")
print(launch_text("[OK] Source is up to date.", "[完成] 源码已是最新版。"))
return 0
if git(root, "status", "--porcelain"):
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
ancestor = git(root, "merge-base", "HEAD", target)
if ancestor != current:
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
git(root, "merge", "--ff-only", target, timeout=30)
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
except (RuntimeError, subprocess.TimeoutExpired) as exc:
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
def _atomic_write(path: Path, text: str) -> None:
if path.read_text(encoding="utf-8") == text:
if path.is_file() and path.read_text(encoding="utf-8") == text:
return
backup = path.with_name(path.name + ".artemis-update.bak")
if not backup.exists():
if path.is_file() and not backup.exists():
shutil.copy2(path, backup)
fd, name = tempfile.mkstemp(prefix=".artemis-update-", dir=path.parent)
try:
@@ -124,87 +154,120 @@ def _atomic_write(path: Path, text: str) -> None:
Path(name).unlink(missing_ok=True)
def _belongs_to_project(config: dict, root: Path) -> bool:
if config.get("url"):
def _is_standard_local_artemis(config: dict) -> bool:
if config.get("url") or config.get("type") not in (None, "stdio"):
return False
args = config.get("args", [])
if args[:2] not in (["-m", "mcp_server"], ["-m", "mcp_server.server"]):
# A hand-written wrapper/uv/SSE command may need custom arguments; leave it alone.
return False
candidates = [config.get("cwd"), config.get("env", {}).get("PYTHONPATH")]
for value in candidates:
if isinstance(value, str) and Path(value).resolve() == root.resolve():
return True
command = config.get("command")
return (
isinstance(command, str)
and Path(command).resolve().parent == (root / ".venv" / "Scripts").resolve()
)
# Running this launcher selects this checkout, including after a user moves
# a release ZIP to a new directory. Do not take over custom wrappers/SSE.
return args in (["-m", "mcp_server"], ["-m", "mcp_server.server"])
def _tool_updates(config: dict, names: tuple[str, ...]) -> dict:
updates = {}
if "enabled_tools" in config:
updates["enabled_tools"] = list(dict.fromkeys([*config["enabled_tools"], *names]))
if isinstance(config.get("tools"), dict):
updates["tools"] = {**{name: {"eager": True} for name in names}, **config["tools"]}
return updates
def _updated_entry(config: dict, template: dict, root: Path, names: tuple[str, ...]) -> dict:
updated = {**config, "command": template["command"], "args": template["args"]}
if "cwd" in template or "cwd" in config:
updated["cwd"] = str(root)
environment = config.get("env", {})
if not isinstance(environment, dict):
raise ValueError("The existing Artemis MCP environment is not an object")
updated["env"] = {**environment, **template["env"]}
if "enabled_tools" in template or "enabled_tools" in config:
updated["enabled_tools"] = list(names)
if "tools" in template or "tools" in config:
current_tools = config.get("tools", {})
if not isinstance(current_tools, dict):
raise ValueError("The existing Artemis MCP tools setting is not an object")
updated["tools"] = {name: {"eager": True, **current_tools.get(name, {})} for name in names}
for key in ("type", "required", "startup_timeout_sec", "connectionTimeoutMs"):
if key in template and key not in updated:
updated[key] = template[key]
return updated
def refresh_json(
path: Path, keys: tuple[str, ...], root: Path, python: str, names: tuple[str, ...]
) -> bool:
if not path.is_file():
path: Path,
keys: tuple[str, ...],
root: Path,
python: str,
names: tuple[str, ...],
template: dict | None = None,
install_missing: bool = False,
) -> bool | None:
if not path.is_file() and not install_missing:
return False
raw = path.read_text(encoding="utf-8")
raw = path.read_text(encoding="utf-8") if path.is_file() else "{}"
data = json.loads(raw)
if not isinstance(data, dict):
raise ValueError("MCP configuration must be a JSON object")
parent = data
for key in keys:
parent = parent.get(key, {})
child = parent.get(key)
if child is None and install_missing:
child = parent[key] = {}
if not isinstance(child, dict):
raise ValueError(f"Invalid MCP configuration section: {key}")
parent = child
config = parent.get("artemis")
if not isinstance(config, dict) or not _belongs_to_project(config, root):
return False
updated = {**config, "command": python, **_tool_updates(config, names)}
if config is None and install_missing:
if template is None:
raise ValueError("A standard MCP template is required for installation")
updated = template
elif isinstance(config, dict) and _is_standard_local_artemis(config):
if template is None:
template = {
"command": python,
"args": ["-m", "mcp_server"],
"env": {"PYTHONPATH": str(root)},
}
updated = _updated_entry(config, template, root, names)
elif config is not None and not isinstance(config, dict):
raise ValueError("The Artemis MCP configuration is not an object")
else:
return None
if updated == config:
return False
parent["artemis"] = updated
path.parent.mkdir(parents=True, exist_ok=True)
_atomic_write(path, json.dumps(data, ensure_ascii=False, indent=2) + "\n")
return True
def refresh_codex(path: Path, root: Path, python: str, names: tuple[str, ...]) -> bool:
if not path.is_file():
return False
raw = path.read_text(encoding="utf-8")
config = tomllib.loads(raw).get("mcp_servers", {}).get("artemis")
if not isinstance(config, dict) or not _belongs_to_project(config, root):
return False
wanted = {"command": python, **_tool_updates(config, names)}
wanted = {key: value for key, value in wanted.items() if config.get(key) != value}
if not wanted:
return False
def _toml_value(value: object) -> str:
if isinstance(value, bool):
return "true" if value else "false"
return json.dumps(value, ensure_ascii=False)
def _toml_inline_table(values: dict) -> str:
return "{ " + ", ".join(f"{key} = {_toml_value(value)}" for key, value in values.items()) + " }"
def _replace_toml_tables(raw: str, assignments: dict[str, dict]) -> str:
"""Update only Artemis TOML tables; preserve every unrelated section and setting."""
lines = raw.splitlines(keepends=True)
inside = False
found = set()
output = []
output: list[str] = []
section = ""
found: dict[str, set[str]] = {name: set() for name in assignments}
seen: set[str] = set()
def append_missing():
for key, value in wanted.items():
if key not in found:
output.append(f"{key} = {json.dumps(value, ensure_ascii=False)}\n")
found.add(key)
def append_missing() -> None:
if section in assignments:
for key, value in assignments[section].items():
if key not in found[section]:
rendered = _toml_inline_table(value) if key == "env" else _toml_value(value)
output.append(f"{key} = {rendered}\n")
found[section].add(key)
# Replace complete assignment spans, including multiline arrays, using TOML parsing.
index = 0
while index < len(lines):
line = lines[index]
if line.lstrip().startswith("["):
if inside:
append_missing()
append_missing()
header = re.sub(r"[\s\"']", "", line.split("#", 1)[0]).strip()
inside = header == "[mcp_servers.artemis]"
match = re.match(r"\s*(\w+)\s*=", line) if inside else None
if match and match[1] in wanted:
section = header[1:-1] if header.startswith("[") and header.endswith("]") else ""
seen.add(section)
match = re.match(r"\s*(\w+)\s*=", line) if section in assignments else None
if match and match[1] in assignments[section]:
key = match[1]
original = line
while True:
@@ -216,73 +279,236 @@ def refresh_codex(path: Path, root: Path, python: str, names: tuple[str, ...]) -
if index >= len(lines):
raise ValueError("Cannot safely parse existing MCP setting")
original += lines[index]
output.append(f"{key} = {json.dumps(wanted[key], ensure_ascii=False)}\n")
found.add(key)
value = assignments[section][key]
rendered = _toml_inline_table(value) if key == "env" else _toml_value(value)
output.append(f"{key} = {rendered}\n")
found[section].add(key)
else:
output.append(line)
index += 1
if inside:
append_missing()
updated = "".join(output)
parsed = tomllib.loads(updated)
if any(parsed["mcp_servers"]["artemis"].get(k) != v for k, v in wanted.items()):
raise ValueError("Could not locate the existing Artemis table safely")
_atomic_write(path, updated)
return updated != raw
append_missing()
for name, values in assignments.items():
if name not in seen:
if output and not output[-1].endswith("\n"):
output.append("\n")
output.append(f"\n[{name}]\n")
for key, value in values.items():
rendered = _toml_inline_table(value) if key == "env" else _toml_value(value)
output.append(f"{key} = {rendered}\n")
return "".join(output)
def refresh_mcp(root: Path) -> None:
def refresh_codex(
path: Path,
root: Path,
python: str,
names: tuple[str, ...],
template: dict | None = None,
install_missing: bool = False,
) -> bool | None:
if not path.is_file() and not install_missing:
return False
raw = path.read_text(encoding="utf-8") if path.is_file() else ""
parsed = tomllib.loads(raw)
config = parsed.get("mcp_servers", {}).get("artemis")
if config is None and install_missing:
if template is None:
raise ValueError("A standard MCP template is required for installation")
updated = template
elif isinstance(config, dict) and _is_standard_local_artemis(config):
if template is None:
template = {
"command": python,
"args": ["-m", "mcp_server"],
"cwd": str(root),
"env": {"PYTHONPATH": str(root)},
"enabled_tools": list(names),
}
updated = _updated_entry(config, template, root, names)
else:
return None
top = {
key: value
for key, value in updated.items()
if key != "env" and (not config or config.get(key) != value)
}
environment = updated["env"]
if config and config.get("env") == environment:
env_updates = {}
else:
env_updates = {
key: value
for key, value in environment.items()
if not config or config.get("env", {}).get(key) != value
}
env_header = "mcp_servers.artemis.env"
has_env_table = re.search(r"(?m)^\s*\[mcp_servers\.artemis\.env\]\s*(?:#.*)?$", raw) is not None
if config and "env" in config and not has_env_table and env_updates:
top["env"] = environment
env_updates = {}
assignments = {"mcp_servers.artemis": top}
if env_updates:
assignments[env_header] = env_updates
if not top and not env_updates:
return False
result = _replace_toml_tables(raw, assignments)
actual = tomllib.loads(result)["mcp_servers"]["artemis"]
if any(actual.get(key) != value for key, value in top.items()) or any(
actual.get("env", {}).get(key) != value for key, value in env_updates.items()
):
raise ValueError("Could not safely refresh the Artemis MCP table")
path.parent.mkdir(parents=True, exist_ok=True)
_atomic_write(path, result)
return result != raw
def refresh_mcp(root: Path) -> int:
sys.path.insert(0, str(root))
from artemis.interfaces.cli.commands.mcp import ARTEMIS_MCP_TOOLS, _get_vscode_user_dir
from artemis.interfaces.cli.commands.mcp import (
ARTEMIS_MCP_TOOLS,
_get_config_snippet,
_get_vscode_user_dir,
)
home = Path.home()
vscode = _get_vscode_user_dir()
appdata = Path(os.environ.get("APPDATA", home / "AppData/Roaming"))
cline_store = vscode / "globalStorage/saoudrizwan.claude-dev"
roo_store = vscode / "globalStorage/rooveterinaryinc.roo-cline"
paths = [
(home / ".gemini/config/mcp_config.json", ("mcpServers",)),
(home / ".gemini/jetski/mcp_config.json", ("mcpServers",)),
(home / ".gemini/antigravity/mcp_config.json", ("mcpServers",)),
(home / ".cursor/mcp.json", ("mcpServers",)),
(home / ".codeium/windsurf/mcp_config.json", ("mcpServers",)),
(home / ".claude.json", ("mcpServers",)),
(appdata / "Claude/claude_desktop_config.json", ("mcpServers",)),
(vscode / "mcp.json", ("servers",)),
(home / ".copilot/mcp-config.json", ("servers",)),
(home / ".cline/data/settings/cline_mcp_settings.json", ("mcpServers",)),
(home / ".cline/mcp.json", ("mcpServers",)),
(home / ".gemini/config/mcp_config.json", ("mcpServers",), "antigravity", home / ".gemini"),
(home / ".gemini/jetski/mcp_config.json", ("mcpServers",), "jetski", home / ".gemini"),
(
vscode / "globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
home / ".gemini/antigravity/mcp_config.json",
("mcpServers",),
"antigravity",
home / ".gemini",
),
(home / ".cursor/mcp.json", ("mcpServers",), "cursor", home / ".cursor"),
(
home / ".codeium/windsurf/mcp_config.json",
("mcpServers",),
"windsurf",
home / ".codeium/windsurf",
),
(home / ".claude.json", ("mcpServers",), "claude", home / ".claude"),
(
appdata / "Claude/claude_desktop_config.json",
("mcpServers",),
"claude",
appdata / "Claude",
),
(vscode / "mcp.json", ("servers",), "vscode", vscode),
(home / ".copilot/mcp-config.json", ("servers",), "vscode", home / ".copilot"),
(
home / ".cline/data/settings/cline_mcp_settings.json",
("mcpServers",),
"cline",
home / ".cline",
),
(home / ".cline/mcp.json", ("mcpServers",), "cline", home / ".cline"),
(
cline_store / "settings/cline_mcp_settings.json",
("mcpServers",),
"cline",
cline_store,
),
(
vscode / "globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
roo_store / "settings/mcp_settings.json",
("mcpServers",),
"roo",
roo_store,
),
(
vscode / "globalStorage/rooveterinaryinc.roo-cline/settings/cline_mcp_settings.json",
roo_store / "settings/cline_mcp_settings.json",
("mcpServers",),
"roo",
roo_store,
),
(root / ".roo/mcp.json", ("mcpServers",)),
(home / ".openclaw/openclaw.json", ("mcp", "servers")),
(root / ".roo/mcp.json", ("mcpServers",), "roo", root / ".roo"),
(home / ".openclaw/openclaw.json", ("mcp", "servers"), "openclaw", home / ".openclaw"),
]
changed = 0
codex = Path(os.environ.get("CODEX_HOME", home / ".codex")) / "config.toml"
for path, keys in [*paths, (codex, ())]:
codex_home = Path(os.environ.get("CODEX_HOME", home / ".codex"))
paths.append((codex_home / "config.toml", (), "codex", codex_home))
installed = updated_count = ready = skipped = errors = 0
for path, keys, client, marker in paths:
if not path.is_file() and not marker.is_dir():
continue
existed = path.is_file()
template = _get_config_snippet(client, sys.executable, str(root))
for key in keys:
template = template[key]
template = template["artemis"] if keys else template["mcp_servers"]["artemis"]
try:
updated = (
refresh_json(path, keys, root, sys.executable, ARTEMIS_MCP_TOOLS)
changed = (
refresh_json(
path,
keys,
root,
sys.executable,
ARTEMIS_MCP_TOOLS,
template=template,
install_missing=True,
)
if keys
else refresh_codex(path, root, sys.executable, ARTEMIS_MCP_TOOLS)
else refresh_codex(
path,
root,
sys.executable,
ARTEMIS_MCP_TOOLS,
template=template,
install_missing=True,
)
)
if updated:
print(f"[UPDATED] Existing MCP configuration: {path}")
changed += 1
if changed:
print(
launch_text(
f"[{'UPDATED' if existed else 'INSTALLED'}] MCP configuration: {path}",
f"[{'已更新' if existed else '已安装'}] MCP 配置:{path}",
)
)
if existed:
updated_count += 1
else:
installed += 1
elif changed is None:
print(
launch_text(
f"[SKIP] Custom Artemis MCP entry in {path}; use the manual installer for this client.",
f"[跳过] {path} 使用自定义 ARTEMIS MCP 启动方式;请手动检查此客户端。",
)
)
skipped += 1
else:
ready += 1
except (OSError, ValueError, TypeError, AttributeError):
print(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(
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:
@@ -314,7 +540,7 @@ if __name__ == "__main__":
if args.action == "source":
sys.exit(update_source(args.root, args.port))
elif args.action == "mcp":
refresh_mcp(args.root)
sys.exit(refresh_mcp(args.root))
else:
marker = args.root / "apps/showcase_ui/node_modules/.artemis-source-hash"
current = frontend_fingerprint(args.root)
+30 -3
View File
@@ -1,6 +1,33 @@
@echo off
rem Use China mirrors for first setup and subsequent launches.
rem All start.bat arguments, including -SetupOnly, are supported.
chcp 65001 >nul 2>&1
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 %*
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
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
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.
@@ -33,7 +33,7 @@ set "ARTEMIS_EXIT_CODE=%ERRORLEVEL%"
if not "%ARTEMIS_EXIT_CODE%"=="0" (
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
)
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"
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(
"url",
[
+8 -15
View File
@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# 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
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)
def test_powershell_script_is_pure_ascii(script):
def test_powershell_script_has_windows_compatible_encoding(script):
raw = script.read_bytes()
offending = []
line_no = 1
for index, byte in enumerate(raw):
if byte == 0x0A:
line_no += 1
elif byte > 0x7F:
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."
)
if any(byte > 0x7F for byte in raw):
assert raw.startswith(b"\xef\xbb\xbf"), (
f"{script.name} contains non-ASCII bytes without a UTF-8 BOM; "
"Windows PowerShell 5.1 would use the system ANSI code page."
)
raw.decode("utf-8-sig")
+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
def test_json_only_updates_existing_matching_project(tmp_path):
def test_json_migrates_existing_standard_mcp_to_current_checkout(tmp_path):
path = tmp_path / "mcp.json"
root = tmp_path / "project"
config = {
@@ -132,19 +132,170 @@ def test_json_only_updates_existing_matching_project(tmp_path):
"autoApprove": [],
}
path.write_text(json.dumps({"mcpServers": {"artemis": config, "other": {"command": "keep"}}}))
assert not update.refresh_json(
path, ("mcpServers",), tmp_path / "different", "new", ("mobile_batch",)
)
assert update.refresh_json(path, ("mcpServers",), root, "new", ("mobile_batch",))
moved_root = tmp_path / "new release"
assert update.refresh_json(path, ("mcpServers",), moved_root, "new", ("mobile_batch",))
result = json.loads(path.read_text())["mcpServers"]
assert result["artemis"]["env"] == {"CUSTOM": "preserved"}
assert result["artemis"]["env"]["CUSTOM"] == "preserved"
assert result["artemis"]["env"]["PYTHONPATH"] == str(moved_root)
assert result["artemis"]["cwd"] == str(moved_root)
assert result["artemis"]["disabled"] is True
assert result["other"] == {"command": "keep"}
assert not update.refresh_json(path, ("mcpServers",), moved_root, "new", ("mobile_batch",))
missing = tmp_path / "not-installed.json"
assert not update.refresh_json(missing, ("mcpServers",), root, "new", ())
assert not missing.exists()
def test_mcp_sync_installs_detected_clients_and_migrates_codex(tmp_path, monkeypatch):
from artemis.interfaces.cli.commands import mcp as mcp_command
home = tmp_path / "home"
home.mkdir()
codex_home = home / ".codex"
codex_home.mkdir()
codex_config = codex_home / "config.toml"
codex_config.write_text(
'model = "my-model"\n'
"[mcp_servers.artemis]\n"
'command = "old-python"\n'
'args = ["-m", "mcp_server.server"]\n'
'cwd = "C:/old-release"\n'
'enabled_tools = ["mobile_run_task", "retired_tool"]\n'
"enabled = false\n"
"[mcp_servers.artemis.env]\n"
'PYTHONPATH = "C:/old-release"\n'
'OPENAI_API_KEY = "keep-secret"\n',
encoding="utf-8",
)
cursor_home = home / ".cursor"
cursor_home.mkdir()
(cursor_home / "mcp.json").write_text('{"mcpServers":{"other":{"command":"keep"}}}')
monkeypatch.setattr("pathlib.Path.home", lambda: home)
monkeypatch.setattr(mcp_command, "_get_vscode_user_dir", lambda: home / "vscode-user")
monkeypatch.setenv("CODEX_HOME", str(codex_home))
monkeypatch.setenv("APPDATA", str(home / "AppData"))
root = tmp_path / "new release"
assert update.refresh_mcp(root) == 0
codex = tomllib.loads(codex_config.read_text(encoding="utf-8"))
entry = codex["mcp_servers"]["artemis"]
assert codex["model"] == "my-model"
assert entry["command"] == update.sys.executable
assert entry["cwd"] == str(root)
assert entry["args"] == ["-m", "mcp_server"]
assert entry["enabled"] is False
assert entry["env"]["OPENAI_API_KEY"] == "keep-secret"
assert entry["env"]["PYTHONPATH"] == str(root)
assert entry["enabled_tools"] == list(mcp_command.ARTEMIS_MCP_TOOLS)
cursor = json.loads((cursor_home / "mcp.json").read_text(encoding="utf-8"))
assert cursor["mcpServers"]["artemis"]["env"]["PYTHONPATH"] == str(root)
assert cursor["mcpServers"]["other"] == {"command": "keep"}
first = codex_config.read_text(encoding="utf-8")
assert update.refresh_mcp(root) == 0
assert codex_config.read_text(encoding="utf-8") == first
def test_mcp_sync_fails_on_invalid_detected_config_without_overwriting(tmp_path, monkeypatch):
from artemis.interfaces.cli.commands import mcp as mcp_command
home = tmp_path / "home"
cursor_home = home / ".cursor"
cursor_home.mkdir(parents=True)
config = cursor_home / "mcp.json"
config.write_text("{invalid json", encoding="utf-8")
monkeypatch.setattr("pathlib.Path.home", lambda: home)
monkeypatch.setattr(mcp_command, "_get_vscode_user_dir", lambda: home / "vscode-user")
monkeypatch.delenv("CODEX_HOME", raising=False)
assert update.refresh_mcp(tmp_path / "project") == 1
assert config.read_text(encoding="utf-8") == "{invalid json"
def test_mcp_sync_installs_codex_when_client_directory_exists(tmp_path, monkeypatch):
from artemis.interfaces.cli.commands import mcp as mcp_command
home = tmp_path / "home"
codex_home = home / ".codex"
codex_home.mkdir(parents=True)
monkeypatch.setattr("pathlib.Path.home", lambda: home)
monkeypatch.setattr(mcp_command, "_get_vscode_user_dir", lambda: home / "vscode-user")
monkeypatch.setenv("CODEX_HOME", str(codex_home))
root = tmp_path / "project"
assert update.refresh_mcp(root) == 0
config = tomllib.loads((codex_home / "config.toml").read_text(encoding="utf-8"))
assert config["mcp_servers"]["artemis"]["cwd"] == str(root)
assert config["mcp_servers"]["artemis"]["enabled_tools"] == list(mcp_command.ARTEMIS_MCP_TOOLS)
def test_codex_install_preserves_existing_user_tables(tmp_path):
path = tmp_path / "config.toml"
path.write_text(
'model = "keep"\n[mcp_servers.other]\ncommand = "other-tool"\n', encoding="utf-8"
)
template = {
"command": "new-python",
"args": ["-m", "mcp_server"],
"cwd": str(tmp_path),
"env": {"PYTHONPATH": str(tmp_path)},
"enabled_tools": ["mobile_run_task"],
}
assert update.refresh_codex(
path,
tmp_path,
"new-python",
("mobile_run_task",),
template=template,
install_missing=True,
)
data = tomllib.loads(path.read_text(encoding="utf-8"))
assert data["model"] == "keep"
assert data["mcp_servers"]["other"]["command"] == "other-tool"
assert data["mcp_servers"]["artemis"]["env"]["PYTHONPATH"] == str(tmp_path)
def test_codex_inline_environment_sync_is_idempotent(tmp_path):
path = tmp_path / "config.toml"
path.write_text(
'[mcp_servers.artemis]\ncommand = "old-python"\n'
'args = ["-m", "mcp_server"]\n'
'env = { PYTHONPATH = "C:/old", OPENAI_API_KEY = "keep-secret" }\n',
encoding="utf-8",
)
template = {
"command": "new-python",
"args": ["-m", "mcp_server"],
"cwd": str(tmp_path),
"env": {"PYTHONPATH": str(tmp_path)},
"enabled_tools": ["mobile_run_task"],
}
assert update.refresh_codex(
path, tmp_path, "new-python", ("mobile_run_task",), template=template
)
data = tomllib.loads(path.read_text(encoding="utf-8"))["mcp_servers"]["artemis"]
assert data["env"]["OPENAI_API_KEY"] == "keep-secret"
assert data["env"]["PYTHONPATH"] == str(tmp_path)
assert not update.refresh_codex(
path, tmp_path, "new-python", ("mobile_run_task",), template=template
)
def test_custom_mcp_wrapper_is_left_unchanged(tmp_path):
path = tmp_path / "mcp.json"
original = '{"mcpServers":{"artemis":{"command":"uv","args":["run","custom"]}}}'
path.write_text(original, encoding="utf-8")
assert (
update.refresh_json(
path,
("mcpServers",),
tmp_path,
"new-python",
("mobile_run_task",),
install_missing=True,
template={"command": "new-python", "args": ["-m", "mcp_server"], "env": {}},
)
is None
)
assert path.read_text(encoding="utf-8") == original
def test_frontend_fingerprint_detects_lock_source_and_deleted_assets(tmp_path):
ui = tmp_path / "apps/showcase_ui"
(ui / "src").mkdir(parents=True)
+112 -14
View File
@@ -1,36 +1,134 @@
import importlib.util
from pathlib import Path
import shutil
import subprocess
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"
spec = importlib.util.spec_from_file_location("package_source", script)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
SCRIPT = Path(__file__).resolve().parents[2] / "scripts/package_source.py"
spec = importlib.util.spec_from_file_location("package_source", SCRIPT)
module = importlib.util.module_from_spec(spec)
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"]
tracked_builds = [
"artemis/resources/showcase_ui/main.js",
"packages/artemis-accessibility-helper/ArtemisAccessibilityHelper.apk",
"packages/artemis-accessibility-helper/debug.keystore",
]
omit = [
".env",
".env.local",
".venv/pyvenv.cfg",
"nested/node_modules/foo.js",
"apps/showcase_ui/dist/main.js",
"artemis/resources/showcase_ui/main.js",
"__pycache__/a.pyc",
"traces/data.db",
"helper.apk",
"debug.keystore",
".git/config",
".roo/mcp.json",
]
for name in keep + omit:
target = tmp_path / name
for name in keep + tracked_builds + omit:
target = root / name
target.parent.mkdir(parents=True, exist_ok=True)
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"
count, digest = module.package_source(tmp_path, output)
assert count == len(keep)
count, digest = module.package_source(root, output)
assert count > len(keep)
assert len(digest) == 64
with zipfile.ZipFile(output) as archive:
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]]
name = "artemis"
version = "1.1.0"
version = "1.1.2"
source = { editable = "." }
dependencies = [
{ name = "adbutils" },