feat: enable full AI automation and safe startup updates
This commit is contained in:
1 parent
2ddec53954
commit
7b1a0755e2
62 files changed
+3123
-240
No files matched your search
@@ -0,0 +1,325 @@
|
||||
"""Check an existing checkout and refresh only already-configured local MCP clients."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import tomllib
|
||||
import urllib.request
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def git(root: Path, *args: str, timeout: int = 15) -> str:
|
||||
env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GCM_INTERACTIVE": "Never"}
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(root), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
env=env,
|
||||
timeout=timeout,
|
||||
)
|
||||
if result.returncode:
|
||||
# Remote diagnostics can contain URLs/userinfo. Keep them out of launcher logs.
|
||||
raise RuntimeError(
|
||||
f"git {args[0]} failed (exit {result.returncode}); check network, credentials or repository state in Git."
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def update_source(root: Path, port: int = 8000) -> int:
|
||||
"""Return 10 after a fast-forward (caller reloads launcher); never overwrite local work."""
|
||||
try:
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
with opener.open(f"http://127.0.0.1:{port}/api/status", timeout=2) as response:
|
||||
status = json.load(response)
|
||||
if (
|
||||
status.get("active_tasks")
|
||||
or status.get("queue")
|
||||
or (status.get("status") == "running" and status.get("pid"))
|
||||
):
|
||||
print(
|
||||
"[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."
|
||||
)
|
||||
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."
|
||||
)
|
||||
return 0
|
||||
try:
|
||||
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 remote.startswith("-") or not merge_ref.startswith("refs/heads/"):
|
||||
raise RuntimeError(
|
||||
"No remote tracking branch; configure an upstream before enabling source updates."
|
||||
)
|
||||
url = git(root, "remote", "get-url", remote)
|
||||
parsed = urlsplit(url)
|
||||
if parsed.scheme in {"http", "https"} and (parsed.username or parsed.password):
|
||||
raise RuntimeError(
|
||||
"Use a clean Git remote URL and a credential manager, not embedded credentials."
|
||||
)
|
||||
print("[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.")
|
||||
return 0
|
||||
if git(root, "status", "--porcelain"):
|
||||
print(
|
||||
"[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."
|
||||
)
|
||||
return 0
|
||||
git(root, "merge", "--ff-only", target, timeout=30)
|
||||
print(
|
||||
f"[UPDATED] Source {current[:12]} -> {target[:12]}. Reloading launcher and checking dependencies."
|
||||
)
|
||||
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."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _atomic_write(path: Path, text: str) -> None:
|
||||
if path.read_text(encoding="utf-8") == text:
|
||||
return
|
||||
backup = path.with_name(path.name + ".artemis-update.bak")
|
||||
if not backup.exists():
|
||||
shutil.copy2(path, backup)
|
||||
fd, name = tempfile.mkstemp(prefix=".artemis-update-", dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as stream:
|
||||
stream.write(text)
|
||||
os.replace(name, path)
|
||||
finally:
|
||||
Path(name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _belongs_to_project(config: dict, root: Path) -> bool:
|
||||
if config.get("url"):
|
||||
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()
|
||||
)
|
||||
|
||||
|
||||
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 refresh_json(
|
||||
path: Path, keys: tuple[str, ...], root: Path, python: str, names: tuple[str, ...]
|
||||
) -> bool:
|
||||
if not path.is_file():
|
||||
return False
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
data = json.loads(raw)
|
||||
parent = data
|
||||
for key in keys:
|
||||
parent = parent.get(key, {})
|
||||
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 updated == config:
|
||||
return False
|
||||
parent["artemis"] = updated
|
||||
_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
|
||||
lines = raw.splitlines(keepends=True)
|
||||
inside = False
|
||||
found = set()
|
||||
output = []
|
||||
|
||||
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)
|
||||
|
||||
# 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()
|
||||
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:
|
||||
key = match[1]
|
||||
original = line
|
||||
while True:
|
||||
try:
|
||||
tomllib.loads(original)
|
||||
break
|
||||
except tomllib.TOMLDecodeError:
|
||||
index += 1
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
def refresh_mcp(root: Path) -> None:
|
||||
sys.path.insert(0, str(root))
|
||||
from artemis.interfaces.cli.commands.mcp import ARTEMIS_MCP_TOOLS, _get_vscode_user_dir
|
||||
|
||||
home = Path.home()
|
||||
vscode = _get_vscode_user_dir()
|
||||
appdata = Path(os.environ.get("APPDATA", home / "AppData/Roaming"))
|
||||
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",)),
|
||||
(
|
||||
vscode / "globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
|
||||
("mcpServers",),
|
||||
),
|
||||
(
|
||||
vscode / "globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
|
||||
("mcpServers",),
|
||||
),
|
||||
(
|
||||
vscode / "globalStorage/rooveterinaryinc.roo-cline/settings/cline_mcp_settings.json",
|
||||
("mcpServers",),
|
||||
),
|
||||
(root / ".roo/mcp.json", ("mcpServers",)),
|
||||
(home / ".openclaw/openclaw.json", ("mcp", "servers")),
|
||||
]
|
||||
changed = 0
|
||||
codex = Path(os.environ.get("CODEX_HOME", home / ".codex")) / "config.toml"
|
||||
for path, keys in [*paths, (codex, ())]:
|
||||
try:
|
||||
updated = (
|
||||
refresh_json(path, keys, root, sys.executable, ARTEMIS_MCP_TOOLS)
|
||||
if keys
|
||||
else refresh_codex(path, root, sys.executable, ARTEMIS_MCP_TOOLS)
|
||||
)
|
||||
if updated:
|
||||
print(f"[UPDATED] Existing MCP configuration: {path}")
|
||||
changed += 1
|
||||
except (OSError, ValueError, TypeError, AttributeError):
|
||||
print(f"[WARN] Could not safely refresh {path}; original configuration preserved.")
|
||||
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."
|
||||
)
|
||||
print("[TIP] For a new client run: uv run artemis mcp --install <client>")
|
||||
|
||||
|
||||
def frontend_fingerprint(root: Path) -> str:
|
||||
ui = root / "apps/showcase_ui"
|
||||
files = list((ui / "src").rglob("*")) + list((ui / "public").rglob("*"))
|
||||
files += [
|
||||
ui / name
|
||||
for name in (
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"angular.json",
|
||||
"tsconfig.json",
|
||||
"tsconfig.app.json",
|
||||
)
|
||||
]
|
||||
digest = hashlib.sha256()
|
||||
for path in sorted(p for p in files if p.is_file()):
|
||||
digest.update(path.relative_to(ui).as_posix().encode())
|
||||
digest.update(path.read_bytes())
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("action", choices=["source", "mcp", "frontend-check", "frontend-mark"])
|
||||
parser.add_argument("--root", type=Path, default=ROOT)
|
||||
parser.add_argument("--port", type=int, default=8000)
|
||||
args = parser.parse_args()
|
||||
if args.action == "source":
|
||||
sys.exit(update_source(args.root, args.port))
|
||||
elif args.action == "mcp":
|
||||
refresh_mcp(args.root)
|
||||
else:
|
||||
marker = args.root / "apps/showcase_ui/node_modules/.artemis-source-hash"
|
||||
current = frontend_fingerprint(args.root)
|
||||
if args.action == "frontend-mark":
|
||||
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
marker.write_text(current, encoding="ascii")
|
||||
elif not marker.is_file() or marker.read_text(encoding="ascii").strip() != current:
|
||||
sys.exit(10)
|
||||
Reference in new issue
Block a user