"""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)^.*?^[^\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__)