Install and update artemis-use with task diagnostics and plugin guidance. Preserve cross-device file receipts, review canonical reports, guard stage transitions and reduce small history summaries. Improve exploration lifecycle, request recovery and browser live connections.
200 lines
7.3 KiB
Python
200 lines
7.3 KiB
Python
"""Install the authoritative artemis-use bundle on user-initiated service startup."""
|
|
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import tempfile
|
|
from uuid import uuid4
|
|
|
|
from filelock import FileLock
|
|
from packaging.version import Version, InvalidVersion
|
|
|
|
from artemis import __version__
|
|
from artemis.integrations.codex_mcp import _atomic_write
|
|
|
|
NAME = "artemis-use"
|
|
STAMP = ".artemis-managed.json"
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def skill_source(root: Path) -> Path:
|
|
for candidate in (
|
|
root / "skills" / NAME,
|
|
Path(__file__).resolve().parents[2] / "skills" / NAME,
|
|
Path(__file__).resolve().parents[1] / "resources" / "skills" / NAME,
|
|
):
|
|
if (candidate / "SKILL.md").is_file():
|
|
return candidate
|
|
raise FileNotFoundError("安装包缺少官方 artemis-use Skill,请修复 ARTEMIS 安装包")
|
|
|
|
|
|
def _links(path: Path) -> bool:
|
|
return path.is_symlink() or path.is_junction()
|
|
|
|
|
|
def _files(path: Path) -> dict[str, str]:
|
|
files = {}
|
|
if _links(path):
|
|
return files
|
|
for item in sorted(path.rglob("*")):
|
|
if _links(item):
|
|
raise ValueError("Skill 目录包含链接,需要替换为官方文件")
|
|
if "__pycache__" in item.parts or item.suffix == ".pyc":
|
|
continue
|
|
if item.is_file() and item.name != STAMP:
|
|
files[item.relative_to(path).as_posix()] = hashlib.sha256(item.read_bytes()).hexdigest()
|
|
return files
|
|
|
|
|
|
def _remove_owned(path: Path) -> None:
|
|
"""Only call for an exact managed child; never traverse a junction/symlink."""
|
|
if path.is_symlink():
|
|
path.unlink()
|
|
elif path.is_junction():
|
|
path.rmdir()
|
|
elif path.is_dir():
|
|
shutil.rmtree(path)
|
|
elif path.exists():
|
|
path.unlink()
|
|
|
|
|
|
def _migrate_rules(codex_home: Path) -> None:
|
|
pattern = re.compile(
|
|
r"(?ms)^<!-- BEGIN ARTEMIS MOBILE TESTING RULES -->.*?^<!-- END ARTEMIS MOBILE TESTING RULES -->[^\S\n]*\n?"
|
|
)
|
|
for name in ("AGENTS.md", "AGENTS.override.md"):
|
|
path = codex_home / name
|
|
if path.is_file() and not _links(path):
|
|
raw = path.read_text(encoding="utf-8")
|
|
updated = pattern.sub("", raw)
|
|
if updated != raw:
|
|
_atomic_write(path, updated)
|
|
|
|
|
|
def sync_official_skill(root: Path) -> dict:
|
|
source = skill_source(root)
|
|
expected = _files(source)
|
|
if not expected or "SKILL.md" not in expected:
|
|
raise ValueError("官方 Skill 内容不完整")
|
|
custom_home = os.environ.get("CODEX_HOME")
|
|
codex_home = Path(custom_home).expanduser() if custom_home else Path.home() / ".codex"
|
|
# Respect explicitly isolated Codex profiles. Otherwise use the documented
|
|
# user discovery location, reusing a pre-existing legacy Codex installation.
|
|
locations = (
|
|
[codex_home / "skills"]
|
|
if custom_home
|
|
else [Path.home() / ".agents" / "skills", codex_home / "skills"]
|
|
)
|
|
parent = next((p for p in locations if (p / NAME).exists()), locations[0])
|
|
parent.mkdir(parents=True, exist_ok=True)
|
|
target = parent / NAME
|
|
# One lock for both supported paths, shared by all installations of this user.
|
|
codex_home.mkdir(parents=True, exist_ok=True)
|
|
with FileLock(str(codex_home / ".artemis-skill.lock"), timeout=5):
|
|
old = {}
|
|
if not _links(target) and (target / STAMP).is_file():
|
|
try:
|
|
old = json.loads((target / STAMP).read_text("utf-8"))
|
|
if not isinstance(old, dict):
|
|
old = {}
|
|
except (ValueError, OSError):
|
|
pass
|
|
try:
|
|
if (
|
|
old.get("name") == NAME
|
|
and old.get("files") == _files(target)
|
|
and Version(str(old.get("artemis_version", "0"))) > Version(__version__)
|
|
):
|
|
return {"status": "newer_installed", "path": str(target)}
|
|
except (InvalidVersion, ValueError):
|
|
pass
|
|
try:
|
|
unchanged = target.is_dir() and _files(target) == expected
|
|
except ValueError:
|
|
unchanged = False
|
|
if not unchanged:
|
|
staging = Path(tempfile.mkdtemp(prefix=".artemis-use-stage-", dir=parent))
|
|
previous = parent / f".artemis-use-old-{uuid4().hex}"
|
|
try:
|
|
shutil.copytree(
|
|
source,
|
|
staging,
|
|
dirs_exist_ok=True,
|
|
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"),
|
|
)
|
|
if _files(staging) != expected:
|
|
raise ValueError("官方 Skill 复制校验失败")
|
|
(staging / STAMP).write_text(
|
|
json.dumps(
|
|
{
|
|
"name": NAME,
|
|
"artemis_version": __version__,
|
|
"source": str(root.resolve()),
|
|
"files": expected,
|
|
},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
if target.exists() or _links(target):
|
|
target.rename(previous)
|
|
try:
|
|
staging.rename(target)
|
|
except OSError:
|
|
if previous.exists() or _links(previous):
|
|
previous.rename(target)
|
|
raise
|
|
finally:
|
|
_remove_owned(staging)
|
|
# Previous content is temporary rollback data, not a customization backup.
|
|
if target.exists():
|
|
_remove_owned(previous)
|
|
for location in locations:
|
|
duplicate = location / NAME
|
|
if duplicate != target and (duplicate.exists() or _links(duplicate)):
|
|
_remove_owned(duplicate)
|
|
_migrate_rules(codex_home)
|
|
return {"status": "unchanged" if unchanged else "updated", "path": str(target)}
|
|
|
|
|
|
def sync_skill_safely(root: Path) -> dict:
|
|
try:
|
|
result = sync_official_skill(root)
|
|
logger.info("Codex 官方 Skill %s: %s", result["status"], result["path"])
|
|
return result
|
|
except Exception as exc:
|
|
# Do not print config/exception bodies which may contain credentials.
|
|
logger.warning(
|
|
"官方 artemis-use Skill 同步失败(%s);保留可用版本,网页继续启动。",
|
|
type(exc).__name__,
|
|
)
|
|
return {"status": "error", "error_type": type(exc).__name__}
|
|
|
|
|
|
def sync_on_startup(root: Path) -> None:
|
|
if os.environ.get("ARTEMIS_SKIP_CODEX_SYNC") == "1":
|
|
return
|
|
sync_skill_safely(root)
|
|
from artemis.interfaces.cli.commands.mcp import ARTEMIS_MCP_TOOLS, _get_config_snippet
|
|
from artemis.integrations.codex_mcp import refresh_codex
|
|
import sys
|
|
|
|
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
|
|
template = _get_config_snippet("codex", sys.executable, str(root))["mcp_servers"]["artemis"]
|
|
try:
|
|
refresh_codex(
|
|
codex_home / "config.toml",
|
|
root,
|
|
sys.executable,
|
|
ARTEMIS_MCP_TOOLS,
|
|
template=template,
|
|
install_missing=True,
|
|
)
|
|
except Exception as exc:
|
|
logger.warning("Codex MCP 同步失败(%s);原配置已保留,网页继续启动。", type(exc).__name__)
|