#!/usr/bin/env bash set -uo pipefail export PYTHONDONTWRITEBYTECODE=1 # The installer is itself an install: it must not race a background auto-update # that replaces the runtime directory underneath it. Every `hephaestus` or # `python -m agentlas_cloud` call this script makes would otherwise arm # maybe_auto_update(), whose detached worker rewrites bin/ on its own schedule — # measured: fresh installs ended with the just-written Windows .cmd wrappers # deleted while the install still reported success. export HEPHAESTUS_AUTO_UPDATE=0 export HEPHAESTUS_UPDATE_CHECK=0 agentlas_installer_python_cache_prefix() { local home_real prefix [[ -n "${HOME:-}" && "$HOME" == /* && -d "$HOME" ]] || return 1 home_real="$(cd -P "$HOME" 2>/dev/null && pwd)" || return 1 case "$(uname -s 2>/dev/null || true)" in Darwin) prefix="$home_real/Library/Caches/Agentlas/python" ;; *) prefix="$home_real/.cache/agentlas/python" ;; esac [[ "$prefix" == /* && "$prefix" != *$'\n'* && "$prefix" != *$'\r'* ]] || return 1 case "$prefix" in *.app/Contents/Resources|*.app/Contents/Resources/*|*/resources|*/resources/*) return 1 ;; esac printf '%s\n' "$prefix" } PYTHONPYCACHEPREFIX="$(agentlas_installer_python_cache_prefix)" || { printf '%s\n' "Agentlas OS installer could not establish a safe external Python cache directory." >&2 exit 78 } export PYTHONPYCACHEPREFIX version="${HEPHAESTUS_REF:-v1.2.47}" repo_was_explicit="${HEPHAESTUS_REPO:-}" github_url_was_explicit="${HEPHAESTUS_GITHUB_URL:-}" repo="${HEPHAESTUS_REPO:-agentlas-ai/Agentlas-OS}" github_url="${HEPHAESTUS_GITHUB_URL:-https://github.com/$repo}" marketplace_name="${HEPHAESTUS_MARKETPLACE:-agentlas-core-engine}" plugin_name="${HEPHAESTUS_PLUGIN:-hephaestus}" old_plugin_name="${HEPHAESTUS_OLD_PLUGIN:-agentlas-meta-agent}" requested_source_dir="${HEPHAESTUS_SOURCE_DIR:-}" source_dir="$requested_source_dir" force="${HEPHAESTUS_FORCE:-1}" # The host-adapter bundle set is declared once, in # contracts/runtime-registry.json → hostAdapters. This script used to restate it # by hand and drifted: amp/warp/amazonq stayed here after they were dropped from # the registry and the adapter tree. Read the contract instead — the updater # (agentlas_cloud/update.py) and the release-asset allowlist read the same block, # and engine discovery in the shipped commands reads the bundle by that path. HOST_ADAPTER_BUNDLE_DIR="host_adapters" host_adapter_dirs=() # Fail closed. A bundle built from a guessed set is exactly the failure this # replaces — a runtime home that looks installed while engine discovery falls # through to "run the installer first". load_host_adapter_contract() { local registry="$source_dir/contracts/runtime-registry.json" local py="" [[ -f "$registry" ]] || { warn "host-adapter contract missing: $registry"; return 1; } # Hook manifests are consumed directly by host parsers. Validate their # top-level schema before staging the runtime so an invalid optional field # cannot reach ~/.codex/plugins/cache and make every hook fail to load. if [[ -x "$source_dir/scripts/verify-windows-wiring.sh" ]]; then bash "$source_dir/scripts/verify-windows-wiring.sh" >/dev/null 2>&1 || { warn "hook/platform wiring validation failed; refusing the runtime install" return 1 } fi py="$(resolve_python_cmd || true)" [[ -n "$py" ]] || { warn "python3 is required to read $registry"; return 1; } local rendered="" rendered="$(run_resolved_python "$py" - "$registry" <<'PY' import json, re, sys block = json.load(open(sys.argv[1], encoding="utf-8")).get("hostAdapters") or {} bundle = block.get("bundleDir") or "" dirs = block.get("dirs") or [] safe = re.compile(r"^\.?[a-z0-9][a-z0-9._-]*$") if not safe.match(bundle) or not dirs: raise SystemExit("hostAdapters block is missing or malformed") for name in dirs: if not isinstance(name, str) or not safe.match(name) or name in (".", ".."): raise SystemExit(f"refusing unsafe host-adapter directory name: {name!r}") print(bundle) print("\n".join(dirs)) PY )" || { warn "could not read the host-adapter contract from $registry"; return 1; } HOST_ADAPTER_BUNDLE_DIR="$(printf '%s\n' "$rendered" | sed -n '1p')" host_adapter_dirs=() local line while IFS= read -r line; do [[ -n "$line" ]] && host_adapter_dirs+=("$line") done <<< "$(printf '%s\n' "$rendered" | sed -n '2,$p')" [[ -n "$HOST_ADAPTER_BUNDLE_DIR" && ${#host_adapter_dirs[@]} -gt 0 ]] || { warn "the host-adapter contract in $registry declared no directories" return 1 } } ok=0 failed=0 tmp_source_dir="" prepared_source_key="" runtime_stage_dir="" installed_runtime_generation="" cleanup() { if [[ -n "$tmp_source_dir" ]]; then rm -rf "$tmp_source_dir" fi if [[ -n "$runtime_stage_dir" && -d "$runtime_stage_dir" ]]; then rm -rf "$runtime_stage_dir" fi } trap cleanup EXIT log() { printf '%s\n' "$*" } warn() { printf 'WARN: %s\n' "$*" >&2 } have() { command -v "$1" >/dev/null 2>&1 } resolve_install_source_identity() { # Validate before download, CLI registration, removal or cache maintenance. # Local sources have no remote repo/URL identity to reconcile. if [[ -n "$requested_source_dir" ]]; then [[ -d "$requested_source_dir" ]] || { warn "install_source_directory_missing"; return 1; } return 0 fi local canonical_repo="${repo%.git}" url_repo="" canonical_url="" canonical_repo="$(printf '%s' "$canonical_repo" | tr '[:upper:]' '[:lower:]')" canonical_url="$(printf '%s' "$github_url" | tr '[:upper:]' '[:lower:]')" canonical_url="${canonical_url%/}" if [[ "$canonical_url" != https://github.com/* ]]; then warn "install_source_url_unsupported" return 1 fi url_repo="${canonical_url#https://github.com/}" url_repo="${url_repo%.git}" local repo_pattern='^[a-z0-9][a-z0-9-]*/[a-z0-9][a-z0-9._-]*$' if [[ ! "$canonical_repo" =~ $repo_pattern || ! "$url_repo" =~ $repo_pattern ]]; then warn "install_source_repository_invalid" return 1 fi if [[ -n "$github_url_was_explicit" && -z "$repo_was_explicit" ]]; then canonical_repo="$url_repo" elif [[ "$canonical_repo" != "$url_repo" ]]; then warn "install_source_identity_conflict" return 1 fi repo="$canonical_repo" github_url="https://github.com/$canonical_repo" } resolve_python_cmd() { agentlas_load_platform_helpers || return 1 agentlas_resolve_python_cmd } # The canonical resolver returns either one executable path or the Windows # launcher vector `py -3`. Never quote that vector as one executable and never # rely on general word splitting for paths supplied through HEPHAESTUS_PYTHON. run_resolved_python() { local py="$1" shift if [[ "$py" == "py -3" ]]; then command py -3 "$@" else "$py" "$@" fi } run() { log "+ $*" "$@" } run_yes() { log "+ $*" printf 'y\n' | "$@" } try() { log "+ $*" "$@" } copy_tree_without_python_cache() { local src="$1" dest="$2" [[ -d "$src" ]] || { warn "copy source directory missing: $src"; return 1; } have tar || { warn "tar is required for cache-free local-source copies."; return 1; } mkdir -p "$dest" || return 1 ( cd "$src" || exit 1 tar --exclude='__pycache__' --exclude='*/__pycache__' \ --exclude='*.pyc' --exclude='*.pyo' -cf - . ) | ( cd "$dest" || exit 1 tar -xf - ) || return 1 find "$dest" -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete 2>/dev/null || return 1 find "$dest" -depth -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || return 1 } atomic_replace_path() { local source_path="$1" destination_path="$2" py="$3" run_resolved_python "$py" - "$source_path" "$destination_path" <<'PY' import os import sys os.replace(sys.argv[1], sys.argv[2]) PY } promote_runtime_home() { local runtime_root="$1" home_dir="$2" stage_dir="$3" expected_release="$4" py="$5" local plain="${home_dir##*/}" local current_link="$runtime_root/current" local generation="" current_tmp="" installed_runtime_generation="" if [[ "$(cat "$stage_dir/RELEASE" 2>/dev/null || true)" != "$expected_release" ]]; then warn "Staged runtime RELEASE does not match $expected_release; refusing promotion." return 1 fi # A directory-copy installation cannot be atomically replaced by a symlink. # Preserve it rather than introduce a missing-current window during migration. if [[ -e "$current_link" && ! -L "$current_link" ]]; then warn "Runtime current is not a symlink; atomic upgrade is unavailable. The existing runtime was preserved." return 1 fi # Never move or overwrite an installed generation, including a same-version # reinstall. SIGKILL before/after the single pointer replacement leaves the # old/new target intact. Unreferenced generations are safe recovery material. mkdir -p "$runtime_root/.generations" || return 1 generation="$(mktemp -d "$runtime_root/.generations/${plain}.XXXXXX")" || return 1 rmdir "$generation" || return 1 mv "$stage_dir" "$generation" || return 1 runtime_stage_dir="" if [[ "${6:-}" == record-adapters ]]; then # Normalize only our own newly minted path (for example a symlinked home). # Registry-provided paths must still be literal canonical strings. generation="$(run_resolved_python "$py" -c 'from pathlib import Path; import sys; print(Path(sys.argv[1]).resolve(strict=True))' "$generation")" || return 1 generation_adapter_receipt record "$generation" || return 1 fi current_tmp="$generation.current" if ! ln -s "$generation" "$current_tmp"; then warn "Could not prepare the new runtime pointer; the existing runtime was preserved." return 1 fi if [[ "$(cat "$current_tmp/RELEASE" 2>/dev/null || true)" != "$expected_release" ]]; then warn "Prepared runtime pointer verification failed; the existing runtime was preserved." return 1 fi if ! atomic_replace_path "$current_tmp" "$current_link" "$py"; then warn "Could not atomically replace the runtime pointer; the existing runtime was preserved." return 1 fi installed_runtime_generation="$generation" return 0 } # Platform helpers (agentlas_is_windows / agentlas_native_path / # agentlas_path_sep / agentlas_mcp_launch) are canonical in # bin/agentlas-python-cache-boundary. The installer SOURCES them from the # downloaded release instead of re-implementing them: a second copy of "how do # we spell a path on Windows" is exactly how every MCP registration below ended # up hardcoding an extensionless bash runner that native Windows cannot spawn. agentlas_load_platform_helpers() { if declare -F agentlas_is_windows >/dev/null 2>&1 \ && declare -F agentlas_resolve_python_cmd >/dev/null 2>&1; then return 0 fi ensure_downloaded_source || return 1 # shellcheck source=/dev/null source "$source_dir/bin/agentlas-python-cache-boundary" || return 1 # Both symbols must land, not just agentlas_is_windows: a stale or # version-skewed source_dir can source cleanly while omitting a newer # function such as agentlas_resolve_python_cmd, which then fails as # "command not found" at the call site instead of here, and every caller # that gates on this guard (resolve_python_cmd, the Windows shim writer, # ...) silently treats that as "python3 not found" or "not on Windows". declare -F agentlas_is_windows >/dev/null 2>&1 \ && declare -F agentlas_resolve_python_cmd >/dev/null 2>&1 } # PYTHONPATH for a runtime root, in the form the interpreter on THIS platform can # read: native path, native separator. The colon-joined POSIX form this replaces # produces one unusable entry on Windows, and the symptom is # `ModuleNotFoundError: No module named 'agentlas_cloud'`. installer_pythonpath() { local root="$1" native sep if agentlas_load_platform_helpers >/dev/null 2>&1; then native="$(agentlas_native_path "$root")" sep="$(agentlas_path_sep)" else native="$root" sep=":" fi if [[ -n "${PYTHONPATH:-}" ]]; then printf '%s%s%s\n' "$native" "$sep" "$PYTHONPATH" else printf '%s\n' "$native" fi } # Absolute path of the installed local Core MCP runner, without extension. runtime_mcp_runner() { printf '%s\n' "$HOME/.agentlas/runtime/current/bin/hephaestus" } # One place decides how a host must spell "launch the local Core MCP server": # command on the first line, then one argument per line. Eight host # registrations render from this, so the platform rule is fixed once instead of # eight times. runtime_mcp_launch_fields() { local runner runner="$(runtime_mcp_runner)" if agentlas_load_platform_helpers; then agentlas_mcp_launch "$runner" else warn "Platform helpers unavailable; assuming a POSIX MCP launch vector." printf '%s\n' "$runner" "mcp" "serve" fi } # Render the launch vector for a config format. Everything goes through # json.dumps because a Windows path carries backslashes, and an unescaped # backslash silently corrupts JSON, TOML and YAML alike. runtime_mcp_launch_render() { local shape="$1" py="" py="$(resolve_python_cmd || true)" [[ -n "$py" ]] || return 1 runtime_mcp_launch_fields | AGENTLAS_RENDER_SHAPE="$shape" run_resolved_python "$py" -c 'import json, os, sys fields = [line.rstrip("\n") for line in sys.stdin if line.strip()] if not fields: raise SystemExit(1) command, args = fields[0], fields[1:] shape = os.environ["AGENTLAS_RENDER_SHAPE"] array = "[" + ", ".join(json.dumps(a) for a in args) + "]" if shape == "json": print(json.dumps({"command": command, "args": args})) elif shape == "toml": print("command = " + json.dumps(command)) print("args = " + array) elif shape == "yaml": print("cmd: " + json.dumps(command)) print("args: " + array) else: raise SystemExit(f"unknown render shape: {shape}")' } # Commands that live in the repo for the repo's own adapter surface and are # never installed into a user's global command directory. project_only_commands=( "meta-agent.md" ) # AgentSkills-spec surfaces installed into ~/.agents plus host-specific skill # homes. The command registry owns this list; the fallback is for archives from # before command-registry.v2 existed and is deliberately kept compatible with # those older releases. managed_skill_names=() managed_skill_fallback=( "agentlas" "agentlas-one" "hephaestus-network" "hephaestus-cloud" "hephaestus-storm" "hephaestus-graph" "hephaestus-upload" ) load_managed_skill_names() { local registry="$source_dir/contracts/command-registry.v2.json" local py="" managed_skill_names=("${managed_skill_fallback[@]}") [[ -f "$registry" ]] || return 0 py="$(resolve_python_cmd || true)" [[ -n "$py" ]] || { warn "python3 unavailable; using the legacy universal skill fallback."; return 0; } local rendered="" rendered="$(run_resolved_python "$py" - "$registry" <<'PY' import json, re, sys data = json.load(open(sys.argv[1], encoding="utf-8")) skills = data.get("universalSkills") if not isinstance(skills, list) or not skills: raise SystemExit("universalSkills is missing or empty") safe = re.compile(r"^[a-z0-9][a-z0-9-]*$") if any(not isinstance(name, str) or not safe.fullmatch(name) for name in skills): raise SystemExit("universalSkills contains an unsafe name") print("\n".join(skills)) PY )" || { warn "could not read universalSkills from $registry; using the legacy universal skill fallback." return 0 } managed_skill_names=() local name while IFS= read -r name; do [[ -n "$name" ]] && managed_skill_names+=("$name") done <<< "$rendered" [[ "${#managed_skill_names[@]}" -gt 0 ]] || managed_skill_names=("${managed_skill_fallback[@]}") log "Command registry selected universal skills: ${managed_skill_names[*]}" } # The managed command set is DERIVED from the release, never typed out again. # Six hardcoded copies of this list is how `agentlas-one` reached no machine and # `hep-graph` reached no installer, while every other command shipped fine. managed_command_files() { ensure_downloaded_source || return 1 local dir="$source_dir/.claude/commands" if [[ ! -d "$dir" ]]; then warn "release is missing .claude/commands; cannot derive the managed command set." return 1 fi local path name skip excluded for path in "$dir"/*.md; do [[ -e "$path" ]] || continue name="$(basename "$path")" skip=0 for excluded in "${project_only_commands[@]}"; do [[ "$name" == "$excluded" ]] && skip=1 done [[ "$skip" == "1" ]] && continue printf '%s\n' "$name" done } # The managed commands a given runtime actually has an adapter for. Intersecting # with what the release ships means a runtime is never asked to install a file # that does not exist, and a newly added command reaches every runtime that # carries it without editing this script again. runtime_command_files() { local adapter_dir="$1" name managed_command_files | while IFS= read -r name; do [[ -f "$adapter_dir/$name" ]] && printf '%s\n' "$name" done } preflight_git() { if have git; then return 0 fi if [[ "$(uname -s)" == "Darwin" ]] && have xcode-select; then warn "git is missing. Starting Apple Command Line Tools installer." xcode-select --install >/dev/null 2>&1 || true fi warn "git is required for Claude/Codex/Gemini marketplace installs. Run git --version after Command Line Tools finishes, then rerun this installer." return 1 } # Extraction must not source code from the archive to find its own verifier. # This bootstrap uses only Python's standard library; runtime dependency checks # still use the canonical platform helper after the source has been verified. resolve_archive_python_cmd() { local candidate if [[ -n "${HEPHAESTUS_PYTHON:-}" ]]; then run_resolved_python "$HEPHAESTUS_PYTHON" -c 'import sys; sys.exit(sys.version_info < (3, 9))' >/dev/null 2>&1 || return 1 printf '%s\n' "$HEPHAESTUS_PYTHON" return 0 fi for candidate in python3 python "py -3"; do if run_resolved_python "$candidate" -c 'import sys; sys.exit(sys.version_info < (3, 9))' >/dev/null 2>&1; then printf '%s\n' "$candidate" return 0 fi done return 1 } prepare_release_source() { local destination="$1" py="$2" local asset="hephaestus-runtime-$version.tar.gz" local archive_url="https://github.com/$repo/releases/download/$version/$asset" run_resolved_python "$py" - "$destination" "$archive_url" <<'PY_RELEASE' import gzip, hashlib, os, re, signal, subprocess, sys, tarfile, time from pathlib import Path, PurePosixPath # Match the updater's compressed archive ceiling; bound work before extraction # and never use tar's link/device/overwrite semantics. MAX_ARCHIVE = 256 * 1024 * 1024 MAX_EXPANDED = 2 * 1024 * 1024 * 1024 MAX_MEMBERS = 50000 MAX_METADATA = 64 * 1024 CHUNK = 1024 * 1024 root = Path(sys.argv[1]) url = sys.argv[2] def interrupted(signum, frame): raise InterruptedError("release_preparation_cancelled") signal.signal(signal.SIGTERM, interrupted) def download(address, target, limit, seconds): child = subprocess.Popen([ "curl", "--proto", "=https", "--proto-redir", "=https", "--tlsv1.2", "-fsSL", "--connect-timeout", "15", "--max-time", str(seconds), "--speed-limit", "1024", "--speed-time", "30", "--max-filesize", str(limit), "--", address, ], stdout=subprocess.PIPE) try: count = 0 with target.open("xb") as output: while True: block = child.stdout.read(CHUNK) if not block: break count += len(block) if count > limit: raise ValueError("release_download_size_exceeded") output.write(block) if child.wait() != 0: raise ValueError("release_download_failed") finally: child.stdout.close() if child.poll() is None: child.kill() child.wait() class BoundedMetadata(tarfile.TarInfo): def _proc_sparse(self, archive): raise ValueError("release_member_type_rejected") def _proc_pax(self, archive): if self.size > MAX_METADATA: raise ValueError("release_metadata_size_exceeded") result = super()._proc_pax(archive) for fields in (archive.pax_headers, result.pax_headers): if len(fields) > 64 or sum(len(str(key)) + len(str(value)) for key, value in fields.items()) > MAX_METADATA: raise ValueError("release_metadata_size_exceeded") return result def _proc_gnulong(self, archive): if self.size > MAX_METADATA: raise ValueError("release_metadata_size_exceeded") return super()._proc_gnulong(archive) class BoundedTarStream: def __init__(self, stream): self.stream, self.count = stream, 0 self.deadline = time.monotonic() + 120 def read(self, size): if time.monotonic() > self.deadline: raise ValueError("release_extraction_timeout") block = self.stream.read(min(size, CHUNK)) self.count += len(block) if self.count > MAX_EXPANDED: raise ValueError("release_expanded_size_exceeded") return block try: archive = root / "release.tar.gz" checksum = root / "release.sha256" download(url, archive, MAX_ARCHIVE, 300) download(url + ".sha256", checksum, 4096, 30) tokens = checksum.read_text(encoding="ascii").split() if not tokens or not re.fullmatch(r"[0-9a-fA-F]{64}", tokens[0]): raise ValueError("release_checksum_invalid") digest = hashlib.sha256() with archive.open("rb") as source: for block in iter(lambda: source.read(CHUNK), b""): digest.update(block) if digest.hexdigest() != tokens[0].lower(): raise ValueError("release_checksum_mismatch") destination = root / "unpacked" destination.mkdir() seen, roots = set(), set() expanded = 0 with gzip.open(archive, "rb") as compressed: with tarfile.open(fileobj=BoundedTarStream(compressed), mode="r|", tarinfo=BoundedMetadata) as tf: for member in tf: # Older Python versions cache TarInfo even in r| mode. Links # are forbidden, so no later member lookup needs this history. tf.members.clear() if len(seen) >= MAX_MEMBERS: raise ValueError("release_member_count_exceeded") if member.sparse is not None or (not member.isdir() and not member.isreg()): raise ValueError("release_member_type_rejected") name = member.name if not name or len(name.encode("utf-8")) > 4096 or "\x00" in name or "\\" in name: raise ValueError("release_path_rejected") relative = PurePosixPath(name) if relative.is_absolute() or ".." in relative.parts: raise ValueError("release_path_rejected") parts = relative.parts if not parts or not re.fullmatch(r"(?:Agentlas-OS|Hephaestus)-[A-Za-z0-9._-]+", parts[0]): raise ValueError("release_root_rejected") # Also reject Windows drive/ADS/device aliases and normalized # path collisions on case-insensitive host filesystems. if any(":" in part or part.endswith((" ", ".")) or re.fullmatch(r"(?i)(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?", part) for part in parts): raise ValueError("release_path_rejected") roots.add(parts[0]) if len(roots) != 1: raise ValueError("release_multiple_roots") key = hashlib.sha256(str(relative).casefold().encode("utf-8")).digest() if key in seen: raise ValueError("release_duplicate_path") seen.add(key) if member.size < 0 or member.size > MAX_EXPANDED - expanded: raise ValueError("release_expanded_size_exceeded") expanded += member.size target = destination.joinpath(*parts) if member.isdir(): if member.size: raise ValueError("release_directory_data_rejected") target.mkdir(parents=True, exist_ok=True) continue if len(parts) == 1: raise ValueError("release_root_not_directory") target.parent.mkdir(parents=True, exist_ok=True) source = tf.extractfile(member) if source is None: raise ValueError("release_member_unreadable") remaining = member.size with source, target.open("xb") as output: while remaining: block = source.read(min(remaining, CHUNK)) if not block: raise ValueError("release_member_truncated") output.write(block) remaining -= len(block) target.chmod(0o755 if member.mode & 0o111 else 0o644) if len(roots) != 1: raise ValueError("release_root_missing") os.replace(destination / next(iter(roots)), root / "source") (root / ".source-ready").write_text(digest.hexdigest(), encoding="ascii") except (Exception, KeyboardInterrupt) as exc: # Finite local errors only: never echo archive-controlled paths or URLs. code = str(exc) if isinstance(exc, (ValueError, InterruptedError)) and re.fullmatch(r"release_[a-z_]+", str(exc)) else "release_preparation_failed" print("WARN: " + code, file=sys.stderr) raise SystemExit(1) PY_RELEASE } ensure_downloaded_source() { resolve_install_source_identity || return 1 if [[ -n "$requested_source_dir" ]]; then [[ -d "$requested_source_dir" ]] || return 1 source_dir="$requested_source_dir" return 0 fi if [[ -n "$tmp_source_dir" && "$prepared_source_key" == "$repo@$version" \ && -f "$tmp_source_dir/.source-ready" && ! -L "$tmp_source_dir/.source-ready" \ && "$(wc -c < "$tmp_source_dir/.source-ready" | tr -d '[:space:]')" == 64 \ && "$(cat "$tmp_source_dir/.source-ready")" =~ ^[0-9a-f]{64}$ \ && -d "$tmp_source_dir/source" && ! -L "$tmp_source_dir/source" ]]; then source_dir="$tmp_source_dir/source" return 0 fi # A failed or interrupted preparation is never a reusable source snapshot. source_dir="" prepared_source_key="" [[ -z "$tmp_source_dir" ]] || rm -rf "$tmp_source_dir" tmp_source_dir="" have curl || { warn "curl is required for runtime install from a remote release."; return 1; } local py="" py="$(resolve_archive_python_cmd)" || { warn "Python 3.9+ is required to safely verify the runtime archive."; return 1; } tmp_source_dir="$(mktemp -d)" || return 1 if ! prepare_release_source "$tmp_source_dir" "$py"; then rm -rf "$tmp_source_dir" tmp_source_dir="" return 1 fi source_dir="$tmp_source_dir/source" prepared_source_key="$repo@$version" } # Runtime-neutral install: every adapter (skills, commands, prompts, MCP) # resolves ~/.agentlas/runtime/current/bin/hephaestus FIRST, so harnesses # without a plugin cache (OpenCode, OpenClaw, Hermes, Cursor, Ollama-launched # local models) still find the runner. install_runtime_home() { ensure_downloaded_source || { warn "runtime home install skipped: no source."; return 1; } load_host_adapter_contract || { warn "runtime home install skipped: unusable host-adapter contract."; return 1; } local plain="${version#v}" if [[ ! "$plain" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then warn "Runtime ref cannot be used as a local version directory: $version" return 1 fi local runtime_root="$HOME/.agentlas/runtime" local home_dir="$runtime_root/$plain" local current_link="$runtime_root/current" mkdir -p "$runtime_root" || return 1 runtime_stage_dir="$(mktemp -d "$runtime_root/.${plain}.staging.XXXXXX")" || return 1 local stage_dir="$runtime_stage_dir" local model_source="$source_dir/assets/model2vec/potion-multilingual-128M-int8" local model_dest="$stage_dir/models/model2vec/potion-multilingual-128M-int8" local py="" py="$(resolve_python_cmd || true)" if [[ -z "$py" ]]; then warn "Python 3.9+ with jsonschema and referencing is required for the runtime contract validator." return 1 fi if ! run_resolved_python "$py" -c 'from jsonschema import Draft202012Validator; from referencing import Registry' >/dev/null 2>&1; then warn "Python dependencies jsonschema and referencing are required for standards-complete package validation." return 1 fi if [[ ! -d "$model_source" ]]; then warn "Bundled Model2Vec asset is missing: $model_source" return 1 fi log "== Hephaestus runtime home ==" # Build and verify away from the live version. The existing runtime remains # callable until every copy, model check, adapter and shim below succeeds. # system-agents/ carries the canonical curator-ruleset.json. one_workspace.py # resolves it as here.parent/system-agents/curator-ruleset.json, so without it # the stop-hook curator falls back to the embedded ruleset on every install and # stamps sha="embedded" into every decision receipt (2026-08-12 set 4). The # release archive ships only that one file under system-agents/. local runtime_dir for runtime_dir in bin agentlas_cloud career_graph contracts ontology schemas templates system-agents; do copy_tree_without_python_cache "$source_dir/$runtime_dir" "$stage_dir/$runtime_dir" || return 1 done # PRD §3.6 — One 지시문이 "운영 절차는 /skills/agentlas-operations/SKILL.md 에 있다"고 # 가리키는데, 그 씨앗의 원본은 런타임 홈의 skills/ 다(one_workspace.py 가 here.parent/skills # 로 찾는다). 목록에 없어서 새 설치본에는 원본 자체가 없었고, 그래서 씨앗 복사가 조용히 # 아무것도 하지 않았다 — One 은 없는 파일을 가리키는 지시를 받았다. if [[ -d "$source_dir/skills" ]]; then copy_tree_without_python_cache "$source_dir/skills" "$stage_dir/skills" || return 1 fi # Hook packs must travel with the runner: `agentlas-one on` installs them, and # a user who never re-runs the installer would otherwise never receive them. local pack for pack in goose openclaw; do [[ -d "$source_dir/$pack" ]] || continue copy_tree_without_python_cache "$source_dir/$pack" "$stage_dir/$pack" || return 1 done cp "$source_dir/package-contract.json" "$stage_dir/package-contract.json" || return 1 # git-archive substitutes the exact tagged commit into this public marker. # A maintainer may also run this installer from a checkout, where the tracked # file still contains the literal export-subst placeholder; resolve HEAD in # that one case so Claude's ledger never inherits a stale release commit. local release_commit="" if [[ -f "$source_dir/release-provenance.json" ]]; then release_commit="$(sed -nE 's/.*"commit"[[:space:]]*:[[:space:]]*"([0-9a-f]{40})".*/\1/p' "$source_dir/release-provenance.json" | head -1)" fi if [[ ! "$release_commit" =~ ^[0-9a-f]{40}$ ]] && command -v git >/dev/null 2>&1; then release_commit="$(git -C "$source_dir" rev-parse --verify 'HEAD^{commit}' 2>/dev/null || true)" fi if [[ ! "$release_commit" =~ ^[0-9a-f]{40}$ ]]; then warn "Release provenance is missing or invalid; refusing to install an unpinned runtime." return 1 fi printf '{\n "schemaVersion": "agentlas.release-provenance.v1",\n "commit": "%s"\n}\n' \ "$release_commit" > "$stage_dir/release-provenance.json" || return 1 mkdir -p "$(dirname "$model_dest")" copy_tree_without_python_cache "$model_source" "$model_dest" || return 1 if ! PYTHONUTF8=1 PYTHONIOENCODING=utf-8 PYTHONPATH="$(installer_pythonpath "$stage_dir")" \ run_resolved_python "$py" -m ontology.model_assets verify "$model_dest" >/dev/null; then warn "Bundled Model2Vec asset failed local checksum/provenance verification; refusing the runtime install." return 1 fi chmod +x "$stage_dir/bin/hephaestus" \ "$stage_dir/bin/ontology" \ "$stage_dir/bin/career-graph" \ "$stage_dir/bin/hep-build" \ "$stage_dir/bin/hep-network" \ "$stage_dir/bin/hep-local" \ "$stage_dir/bin/hep-cloud" \ "$stage_dir/bin/hep-hub" \ "$stage_dir/bin/hep-search" \ "$stage_dir/bin/hep-browser" \ "$stage_dir/bin/hep-call" \ "$stage_dir/bin/hep-upload" \ "$stage_dir/bin/hep-storm" \ "$stage_dir/bin/hep-global" \ "$stage_dir/bin/hep-update" \ "$stage_dir/bin/agentlas-memory-hook" \ "$stage_dir/bin/agentlas-one" 2>/dev/null || true printf '%s\n' "$version" > "$stage_dir/RELEASE" # The host-adapter bundle. The updater builds this from HOST_ADAPTER_DIRS # (agentlas_cloud/update.py) and the commands that find the engine look for it: # `.claude/commands/hep-build.md` and `agentlas.md` probe # `runtime/current/host_adapters/{claude,codex}/plugins/agentlas-core-engine-meta-agent` # for an AGENTS.md + package-contract.json pair. This script never wrote it, so # a runtime installed HERE and one installed by the updater had different # layouts, and engine discovery fell through to "run the installer first" on a # machine that had just run the installer. Same set, same shape, both paths. local adapter_bundle="$stage_dir/$HOST_ADAPTER_BUNDLE_DIR" mkdir -p "$adapter_bundle" local adapter for adapter in "${host_adapter_dirs[@]}"; do [[ -d "$source_dir/$adapter" ]] || continue copy_tree_without_python_cache "$source_dir/$adapter" "$adapter_bundle/$adapter" || return 1 done for adapter in manifest.json; do [[ -f "$source_dir/$adapter" ]] && cp "$source_dir/$adapter" "$adapter_bundle/$adapter" done if [[ -f "$source_dir/scripts/install-memory-hooks.py" ]]; then mkdir -p "$adapter_bundle/scripts" cp "$source_dir/scripts/install-memory-hooks.py" "$adapter_bundle/scripts/install-memory-hooks.py" fi printf '%s\n' "$version" > "$adapter_bundle/RELEASE" write_python3_shim "$stage_dir/bin" || true write_windows_command_shims "$stage_dir/bin" || true if [[ ! -e "$stage_dir/bin/Hephaestus" ]]; then ln -sfn hephaestus "$stage_dir/bin/Hephaestus" 2>/dev/null || true fi # Agentlas Terminal owns the `agentlas` shell command as an independent # product surface. Core must not shadow it with a Hephaestus alias. rm -f "$stage_dir/bin/agentlas" 2>/dev/null || true rm -f "$stage_dir/bin/Hephaestus-build" "$stage_dir/bin/Hephaestus-search" \ "$stage_dir/bin/Hephaestus-call" "$stage_dir/bin/Hephaestus-storm" \ "$stage_dir/bin/hephaestus-network" \ "$stage_dir/bin/hephaestus-build" "$stage_dir/bin/hephaests-network" \ "$stage_dir/bin/hephaestus-search" "$stage_dir/bin/hephaestus-call" \ "$stage_dir/bin/hephaestus-storm" 2>/dev/null || true # Commit only after the staged runtime is complete. Python's os.replace is a # same-filesystem atomic rename on both macOS and Linux and, unlike `mv -f`, # does not follow an existing `current` symlink as a destination directory. promote_runtime_home "$runtime_root" "$home_dir" "$stage_dir" "$version" "$py" record-adapters \ || return 1 log "Installed runner: $HOME/.agentlas/runtime/current/bin/hephaestus" prune_runtime_homes || warn "Old runtime home versions were left in place." local user_bin="$HOME/.local/bin" if mkdir -p "$user_bin" 2>/dev/null; then # Older Core releases installed this exact managed shim. Remove only that # retired alias; preserve an independently installed Agentlas Terminal # launcher or any other user-owned command. local legacy_agentlas_shim="$user_bin/agentlas" local legacy_agentlas_exec="exec \"$current_link/bin/agentlas\" \"\$@\"" if [[ -f "$legacy_agentlas_shim" ]] \ && [[ "$(sed -n '1p' "$legacy_agentlas_shim")" == "#!/usr/bin/env bash" ]] \ && [[ "$(sed -n '2p' "$legacy_agentlas_shim")" == "$legacy_agentlas_exec" ]] \ && [[ "$(wc -l < "$legacy_agentlas_shim" | tr -d '[:space:]')" == "2" ]]; then rm -f "$legacy_agentlas_shim" log "Removed retired Core-owned agentlas alias; Agentlas Terminal keeps command ownership." fi # agentlas-one belongs here: it is the documented switch for the persistent # personal agent, and leaving it out of this list is why `agentlas-one on` # was not a command on any machine — the runner shipped, but nothing put it # on PATH. local -a shell_commands=( hephaestus ontology hep-build hep-network hep-local hep-cloud hep-hub hep-search hep-browser hep-call hep-upload hep-storm hep-global hep-update agentlas-one ) local command local windows_shims=0 local shim_failures=0 agentlas_load_platform_helpers >/dev/null 2>&1 || true for command in "${shell_commands[@]}"; do rm -f "$user_bin/$command" 2>/dev/null || true # Checked, not fire-and-forget: with a read-only ~/.local/bin every one of # these writes fails and the installer used to still print # "Failed runtimes: 0", so a user had no reason to suspect that NONE of the # commands had been installed. if ! cat > "$user_bin/$command" </dev/null || true # A bash shim is invisible to cmd.exe and PowerShell, so on Windows the # same command also needs a .cmd sibling. Without it none of these # commands exist outside Git Bash. if declare -F agentlas_is_windows >/dev/null 2>&1 && agentlas_is_windows; then rm -f "$user_bin/$command.cmd" 2>/dev/null || true { printf '@echo off\r\n' printf 'setlocal\r\n' printf '"%s" %%*\r\n' "$(agentlas_native_path "$current_link/bin/$command.cmd")" printf 'if errorlevel 1 exit /b %%ERRORLEVEL%%\r\n' } > "$user_bin/$command.cmd" 2>/dev/null && windows_shims=$((windows_shims + 1)) fi done if [[ -x "$user_bin/hephaestus" ]]; then case ":$PATH:" in *":$user_bin:"*) log "Installed shell commands: ${shell_commands[*]}" ;; *) # "add it to PATH" with no line to add is where a clean exit-0 # install leaves someone unable to run anything by name. Measured on # a fresh account: every wrapper works by absolute path and none by # name. Give the exact command, and the path that always works. log "Installed shell commands in $user_bin" if ! printf '%s' ":${PATH}:" | grep -q ":${user_bin}:"; then log " $user_bin is not on your PATH yet. To use them by name:" log " echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.zshrc && exec zsh" log " Until then the absolute path always works:" log " $HOME/.agentlas/runtime/current/bin/hephaestus doctor" fi ;; esac fi if [[ "$windows_shims" -gt 0 ]]; then log "Installed $windows_shims Windows .cmd shims in $user_bin (add it to PATH for cmd.exe and PowerShell)." fi if [[ "$shim_failures" -gt 0 ]]; then warn "$shim_failures of ${#shell_commands[@]} shell commands could not be written to $user_bin (is it writable?)." return 1 fi fi # The portable Core is a complete installed surface even when no optional # host application is present. Count it so a clean/headless machine does not # receive exit 1 after a usable runtime was atomically promoted. ok=$((ok + 1)) } write_python3_shim() { local bin_dir="$1" local py py_cache py_cache_quoted py="$(resolve_python_cmd || true)" rm -f "$bin_dir/python3" "$bin_dir/python3.cmd" 2>/dev/null || true [[ -n "$py" ]] || return 0 py_cache="$(agentlas_installer_python_cache_prefix)" || return 1 printf -v py_cache_quoted '%q' "$py_cache" mkdir -p "$bin_dir" if [[ "$py" == "py -3" ]]; then cat > "$bin_dir/python3" < "$bin_dir/python3.cmd" <<'EOF' @echo off setlocal set "PYTHONDONTWRITEBYTECODE=1" if defined LOCALAPPDATA (set "PYTHONPYCACHEPREFIX=%LOCALAPPDATA%\Agentlas\PythonCache") else (set "PYTHONPYCACHEPREFIX=%TEMP%\Agentlas-PythonCache") py -3 %* exit /b %ERRORLEVEL% EOF else cat > "$bin_dir/python3" < "$bin_dir/python3.cmd" < "$bin_dir/hephaestus.cmd" <<'EOF' @echo off setlocal set "PYTHONUTF8=1" set "PYTHONIOENCODING=utf-8" set "PYTHONDONTWRITEBYTECODE=1" if defined LOCALAPPDATA (set "PYTHONPYCACHEPREFIX=%LOCALAPPDATA%\Agentlas\PythonCache") else (set "PYTHONPYCACHEPREFIX=%TEMP%\Agentlas-PythonCache") set "PYTHONPATH=%~dp0..;%PYTHONPATH%" if defined HEPHAESTUS_PYTHON goto use_env_python if exist "%~dp0python3.cmd" goto use_python3_shim where py >nul 2>nul if not errorlevel 1 goto use_py_launcher where python >nul 2>nul if not errorlevel 1 goto use_path_python echo hephaestus: Python 3.9+ not found. Install Python from python.org and rerun hephaestus doctor. 1>&2 exit /b 127 :use_env_python "%HEPHAESTUS_PYTHON%" -m agentlas_cloud %* exit /b %ERRORLEVEL% :use_python3_shim call "%~dp0python3.cmd" -m agentlas_cloud %* exit /b %ERRORLEVEL% :use_py_launcher py -3 -m agentlas_cloud %* exit /b %ERRORLEVEL% :use_path_python python -m agentlas_cloud %* exit /b %ERRORLEVEL% EOF cat > "$bin_dir/hephaestus-env.cmd" <<'EOF' @echo off set "PYTHONUTF8=1" set "PYTHONIOENCODING=utf-8" set "PYTHONDONTWRITEBYTECODE=1" if defined LOCALAPPDATA (set "PYTHONPYCACHEPREFIX=%LOCALAPPDATA%\Agentlas\PythonCache") else (set "PYTHONPYCACHEPREFIX=%TEMP%\Agentlas-PythonCache") set "PYTHONPATH=%~dp0..;%PYTHONPATH%" EOF chmod +x "$bin_dir/python3" } # Every shell command in the runtime is a bash script, and cmd.exe cannot run a # bash script or an extensionless file at all. On Windows each one therefore # needs a .cmd sibling that hands the script to the bash that is running this # installer — resolved to an absolute native path, because bash.exe is usually # NOT on the PATH that cmd.exe and PowerShell see. # # hephaestus.cmd is deliberately excluded: it is the native Python entrypoint # written by write_python3_shim and must not be routed through bash. write_windows_command_shims() { local bin_dir="$1" agentlas_load_platform_helpers >/dev/null 2>&1 || return 0 agentlas_is_windows || return 0 local bash_path bash_native bash_path="$(command -v bash 2>/dev/null || true)" [[ -n "$bash_path" ]] || { warn "bash not found; skipped Windows command shims."; return 0; } bash_native="$(agentlas_native_path "$bash_path")" || return 0 # Under MSYS, `command -v bash` answers with MSYS's own virtual mount path # (/usr/bin/bash), which is NOT a filesystem path cmd.exe can execute — and # without cygpath there is nothing to convert it with. A bare `bash` at least # resolves through the PATH cmd.exe has; baking in an unresolvable absolute # path guarantees failure. case "$bash_native" in [A-Za-z]:\\*|[A-Za-z]:/*) ;; *) warn "Could not express $bash_path as a Windows path; .cmd wrappers will resolve 'bash' from PATH." bash_native="bash" ;; esac local name script written=0 for script in "$bin_dir"/*; do [[ -f "$script" ]] || continue name="$(basename "$script")" case "$name" in # python3/hephaestus own native .cmd entrypoints written by # write_python3_shim; agentlas-python-cache-boundary is a sourced library, # and a command that only ever no-ops is worse than no command. *.cmd|python3|hephaestus|Hephaestus|agentlas-python-cache-boundary) continue ;; esac # Only wrap actual shell scripts; a data file must not become a command. head -1 "$script" 2>/dev/null | grep -q '^#!.*sh' || continue { printf '@echo off\r\n' printf 'setlocal\r\n' printf '"%s" "%s" %%*\r\n' "$bash_native" "$(agentlas_native_path "$script")" printf 'exit /b %%ERRORLEVEL%%\r\n' } > "$bin_dir/$name.cmd" 2>/dev/null && written=$((written + 1)) done [[ "$written" -gt 0 ]] && log "Wrote $written Windows .cmd wrappers in $bin_dir" return 0 } # AgentSkills-spec universal skill: ~/.agents/skills is read natively by # Codex (USER scope), OpenCode, OpenClaw, Cursor, and Crush. install_agents_skills() { ensure_downloaded_source || return 1 # Every other install step guards its writes; this one did not, so an # unwritable ~/.agents (read-only mount, root-owned directory, full disk) let # the function fall through to its own success log and return 0. The five # skills a user then cannot see were reported as installed. mkdir -p "$HOME/.agents/skills" 2>/dev/null \ || { warn "Could not create ~/.agents/skills (is it writable?); universal skills were not installed."; return 1; } local name src for name in "${managed_skill_names[@]}"; do # `skills/` is canonical. `.agents/skills/` is a rendered mirror and may # come from an older checkout; preferring it here once propagated a stale # personal `Hope` description into every fresh universal-skill install. src="$source_dir/skills/$name" [[ -d "$src" ]] || src="$source_dir/.agents/skills/$name" [[ -d "$src" ]] || { warn "canonical $name skill not found."; return 1; } rm -rf "$HOME/.agents/skills/$name" copy_tree_without_python_cache "$src" "$HOME/.agents/skills/$name" \ || { warn "Could not write ~/.agents/skills/$name (is it writable?)."; return 1; } # cp can report success on a partial copy when the destination fills up, and # the only file that makes a skill a skill is SKILL.md. [[ -f "$HOME/.agents/skills/$name/SKILL.md" ]] \ || { warn "~/.agents/skills/$name was written without SKILL.md; the skill would never load."; return 1; } done log "Installed universal skills: ${managed_skill_names[*]}" # AgentSkills are a real install target shared by current hosts, not merely # an adapter side effect. Count this success independently of host detection. ok=$((ok + 1)) } remove_claude_existing() { try claude plugin uninstall "$plugin_name@$marketplace_name" >/dev/null 2>&1 || true try claude plugin uninstall "$old_plugin_name@$marketplace_name" >/dev/null 2>&1 || true try claude plugin marketplace remove "$marketplace_name" >/dev/null 2>&1 || true rm -rf "$HOME/.claude/plugins/cache/$marketplace_name/$plugin_name" 2>/dev/null || true rm -rf "$HOME/.claude/plugins/cache/$marketplace_name/$old_plugin_name" 2>/dev/null || true } # Resolve only the immutable generation successfully promoted by this process. # Neither the mutable current pointer nor the downloaded temporary tree is a # durable marketplace source. Existing registrations are read, never replaced. accepted_marketplace_source() { local host="$1" py="" [[ -n "$installed_runtime_generation" ]] || { warn "marketplace_generation_unavailable"; return 1; } py="$(resolve_archive_python_cmd)" || return 1 run_resolved_python "$py" - "$installed_runtime_generation" "$HOST_ADAPTER_BUNDLE_DIR" "$host" "$marketplace_name" "$plugin_name" "$version" <<'PY_SOURCE' import json, sys from pathlib import Path try: generation = Path(sys.argv[1]).resolve(strict=True) bundle = (generation / sys.argv[2]).resolve(strict=True) if generation not in bundle.parents: raise ValueError() host, name, plugin, version = sys.argv[3:] if (generation / "RELEASE").read_text(encoding="ascii").strip() != version: raise ValueError() root = (bundle / "claude").resolve(strict=True) if host == "claude" else bundle if root != bundle and bundle not in root.parents: raise ValueError() metadata = root / (".claude-plugin/marketplace.json" if host == "claude" else ".agents/plugins/marketplace.json") if root not in metadata.resolve(strict=True).parents or metadata.stat().st_size > 1024 * 1024: raise ValueError() document = json.loads(metadata.read_text(encoding="utf-8")) if document.get("name") != name: raise ValueError() entries = [item for item in document.get("plugins", []) if isinstance(item, dict) and item.get("name") == plugin] if len(entries) != 1: raise ValueError() declared = entries[0].get("source") if host == "codex": if not isinstance(declared, dict) or declared.get("source") != "local": raise ValueError() declared = declared.get("path") if not isinstance(declared, str) or Path(declared).is_absolute(): raise ValueError() content = (root / declared).resolve(strict=True) if root not in content.parents or not content.is_dir(): raise ValueError() plugin_manifest = content / (".claude-plugin/plugin.json" if host == "claude" else ".codex-plugin/plugin.json") if content not in plugin_manifest.resolve(strict=True).parents or plugin_manifest.stat().st_size > 1024 * 1024: raise ValueError() plugin_document = json.loads(plugin_manifest.read_text(encoding="utf-8")) if plugin_document.get("name") != plugin or plugin_document.get("version") != version.removeprefix("v"): raise ValueError() print(root) except Exception: print("WARN: marketplace_generation_invalid", file=sys.stderr) raise SystemExit(1) PY_SOURCE } # A receipt in a release tree is not authority. Each completed installer run # separately records its generation in owner-only state outside that tree. # Reuse is bounded to identical adapters and source provenance, never version. generation_adapter_receipt() { local action="$1" generation="$2" candidate="${3:-}" host="${4:-}" py="" mode="remote" digest="" py="$(resolve_archive_python_cmd)" || return 1 if [[ -n "$requested_source_dir" ]]; then mode="local" elif [[ "$action" == record ]]; then [[ "$prepared_source_key" == "$repo@$version" && -n "$tmp_source_dir" ]] || { warn "generation_source_receipt_unavailable"; return 1; } digest="$(cat "$tmp_source_dir/.source-ready" 2>/dev/null)" || return 1 fi run_resolved_python "$py" - "$action" "$generation" "$candidate" "$host" "$HOST_ADAPTER_BUNDLE_DIR" "$repo" "$version" "$mode" "$digest" <<'PY_GENERATION' import hashlib, json, os, re, stat, sys, time, uuid from pathlib import Path # Bounds apply to the entire tree, not individual files. No silent truncation. MAX_ENTRIES, MAX_BYTES, MAX_PATH, CHUNK = 20000, 512 * 1024 * 1024, 4096, 1024 * 1024 DEADLINE = time.monotonic() + 120 observed_trees = [] observed_metadata = [] def check_time(): if time.monotonic() > DEADLINE: raise ValueError("generation_tree_timeout") def signature(s): return (s.st_dev, s.st_ino, s.st_mode, s.st_size, s.st_mtime_ns, s.st_ctime_ns, s.st_nlink) def canonical(value): p = Path(value) if not p.is_absolute() or str(p.resolve(strict=True)) != value: raise ValueError("generation_path_invalid") return p def owned_directory(p): s = p.lstat() if not stat.S_ISDIR(s.st_mode) or s.st_uid != os.getuid() or s.st_mode & 0o022: raise ValueError("generation_owner_unavailable") return signature(s) def open_file(path, limit): # Metadata reads use the same no-follow, regular-file and identity fence. fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) try: before = os.fstat(fd) if not stat.S_ISREG(before.st_mode) or before.st_nlink != 1 or before.st_size > limit: raise ValueError("generation_metadata_invalid") data = b"" while len(data) <= limit: check_time() part = os.read(fd, min(CHUNK, limit + 1 - len(data))) if not part: break data += part if len(data) > limit or signature(before) != signature(os.fstat(fd)) or signature(before) != signature(os.stat(path, follow_symlinks=False)): raise ValueError("generation_metadata_changed") observed_metadata.append((path, signature(before))) return data finally: os.close(fd) def verify_metadata(): for path, observed in observed_metadata: check_time() if observed != signature(os.stat(path, follow_symlinks=False)): raise ValueError("generation_metadata_changed") def tree_digest(root): def scan(hash_files): entries, total, digests = {}, 0, [] def walk(fd, prefix): nonlocal total check_time() before = os.fstat(fd) with os.scandir(fd) as iterator: names = [] for row in iterator: check_time() if len(names) + len(entries) >= MAX_ENTRIES: raise ValueError("generation_tree_entry_limit") names.append(row.name) for name in sorted(names): check_time() relative = prefix + name if len(relative.encode('utf-8')) > MAX_PATH or len(entries) >= MAX_ENTRIES: raise ValueError("generation_tree_entry_limit") observed = os.stat(name, dir_fd=fd, follow_symlinks=False) if not stat.S_ISREG(observed.st_mode) and not stat.S_ISDIR(observed.st_mode): raise ValueError("generation_tree_type_invalid") child = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | (os.O_DIRECTORY if stat.S_ISDIR(observed.st_mode) else 0), dir_fd=fd) try: s = os.fstat(child) if signature(s) != signature(observed): raise ValueError("generation_tree_changed") entries[relative] = signature(s) checksum = None if stat.S_ISDIR(s.st_mode): walk(child, relative + '/') else: total += s.st_size if s.st_nlink != 1 or total > MAX_BYTES: raise ValueError("generation_tree_byte_limit") if hash_files: digest = hashlib.sha256() count = 0 while True: check_time() block = os.read(child, CHUNK) if not block: break count += len(block) if count > s.st_size: raise ValueError("generation_tree_changed") digest.update(block) if count != s.st_size: raise ValueError("generation_tree_changed") checksum = digest.hexdigest() if signature(s) != signature(os.fstat(child)) or signature(s) != signature(os.stat(name, dir_fd=fd, follow_symlinks=False)): raise ValueError("generation_tree_changed") digests.append([relative, stat.S_IMODE(s.st_mode), 'directory' if stat.S_ISDIR(s.st_mode) else 'file', checksum]) finally: os.close(child) if signature(before) != signature(os.fstat(fd)): raise ValueError("generation_tree_changed") fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) try: anchor = signature(os.fstat(fd)) walk(fd, '') if anchor != signature(os.stat(root, follow_symlinks=False)): raise ValueError("generation_tree_changed") finally: os.close(fd) return entries, digests, anchor first, digests, anchor = scan(True) second, _, second_anchor = scan(False) if first != second or anchor != second_anchor: raise ValueError("generation_tree_changed") def verify_observation(): final, _, final_anchor = scan(False) if final != first or final_anchor != anchor: raise ValueError("generation_tree_changed") observed_trees.append(verify_observation) return hashlib.sha256(json.dumps([stat.S_IMODE(anchor[2]), sorted(digests)], ensure_ascii=True, separators=(',', ':')).encode()).hexdigest() def production_binding(generation, bundle): release = open_file(generation / 'RELEASE', 256).decode('ascii').strip() provenance = json.loads(open_file(generation / 'release-provenance.json', 4096)) if not isinstance(provenance, dict) or provenance.get('schemaVersion') != 'agentlas.release-provenance.v1' or not re.fullmatch('[0-9a-f]{40}', str(provenance.get('commit', ''))): raise ValueError("generation_provenance_invalid") return {'release': release, 'provenance': provenance, 'adapterDigest': tree_digest(canonical(str(generation / bundle)))} def write_exclusive(path, data): fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) with os.fdopen(fd, 'wb') as out: out.write(data) out.flush() os.fsync(out.fileno()) try: action, fresh, actual, host, bundle, repo, ref, mode, archive = sys.argv[1:] # Native Windows ownership/ACL verification needs a separate implementation. if os.name != 'posix' or not hasattr(os, 'O_NOFOLLOW') or not hasattr(os, 'getuid'): if action == 'record': print('WARN: generation_reuse_ownership_unsupported', file=sys.stderr) raise SystemExit(0) raise ValueError("generation_owner_unavailable") generation = canonical(fresh) generations = canonical(str(generation.parent)) if generations.name != '.generations' or '/' in bundle or bundle in ('', '.', '..'): raise ValueError("generation_path_invalid") runtime = canonical(str(generations.parent)) anchors = [(p, owned_directory(p)[:2]) for p in (runtime, generations, generation)] index = runtime / '.generation-receipts' if action == 'record': index.mkdir(mode=0o700, exist_ok=True) owned_directory(index) index_anchor = signature(index.stat())[:2] if action == 'record': if mode not in ('local', 'remote') or (mode == 'remote' and not re.fullmatch('[0-9a-f]{64}', archive)): raise ValueError("generation_source_receipt_unavailable") binding = production_binding(generation, bundle) if binding['release'] != ref: raise ValueError("generation_provenance_invalid") receipt = {'schema': 'agentlas.installer-generation.v1', 'generation': str(generation), 'binding': binding, 'source': {'mode': mode, 'repo': repo, 'ref': ref, 'archiveSha256': archive if mode == 'remote' else None}} data = json.dumps(receipt, sort_keys=True, separators=(',', ':')).encode() # An archive cannot supply the independent authority entry. Never adopt # an existing receipt, even if it happens to contain matching fields. for verify in observed_trees: verify() verify_metadata() write_exclusive(generation / '.adapter-receipt.json', data) temporary = index / ('.pending-' + uuid.uuid4().hex) try: write_exclusive(temporary, data) os.link(temporary, index / (generation.name + '.json')) finally: temporary.unlink(missing_ok=True) elif action == 'compare': old_root = canonical(actual) old = old_root.parent.parent if host == 'claude' else old_root.parent expected_root = old / bundle / 'claude' if host == 'claude' else old / bundle if old.parent != generations or old_root != expected_root: raise ValueError("generation_path_invalid") def read_receipt(candidate): owned_directory(candidate) authority = index / (candidate.name + '.json') state = authority.lstat() if state.st_uid != os.getuid() or state.st_mode & 0o077: raise ValueError("generation_owner_unavailable") data = open_file(authority, 16384) if data != open_file(candidate / '.adapter-receipt.json', 16384): raise ValueError("generation_receipt_changed") receipt = json.loads(data) if receipt.get('schema') != 'agentlas.installer-generation.v1' or receipt.get('generation') != str(candidate) or receipt.get('binding') != production_binding(candidate, bundle): raise ValueError("generation_receipt_changed") if data != open_file(authority, 16384) or data != open_file(candidate / '.adapter-receipt.json', 16384): raise ValueError("generation_receipt_changed") return receipt left, right = read_receipt(generation), read_receipt(old) if left['source'] != right['source'] or left['binding'] != right['binding']: raise ValueError("generation_source_conflict") for verify in observed_trees: verify() verify_metadata() if signature(index.stat())[:2] != index_anchor or any(owned_directory(p)[:2] != anchor for p, anchor in anchors): raise ValueError("generation_receipt_changed") print(old_root) else: raise ValueError("generation_action_invalid") except (Exception, KeyboardInterrupt) as exc: code = str(exc) if isinstance(exc, ValueError) and re.fullmatch('generation_[a-z_]+', str(exc)) else 'generation_receipt_unavailable' print('WARN: ' + code, file=sys.stderr) raise SystemExit(1) PY_GENERATION } marketplace_registration_status() { local host="$1" desired="$2" py="" py="$(resolve_archive_python_cmd)" || return 1 run_resolved_python "$py" - "$host" "$desired" "$marketplace_name" <<'PY_REGISTRY' import json, subprocess, sys from pathlib import Path try: host, desired, name = sys.argv[1:] if not Path(desired).is_absolute() or str(Path(desired).resolve(strict=True)) != desired: raise ValueError() reply = subprocess.run([host, "plugin", "marketplace", "list", "--json"], capture_output=True, timeout=15) if reply.returncode or len(reply.stdout) > 1024 * 1024: raise ValueError() document = json.loads(reply.stdout) entries = document if host == "claude" else document.get("marketplaces") if not isinstance(entries, list) or any(not isinstance(item, dict) for item in entries): raise ValueError() matches = [item for item in entries if item.get("name") == name] if not matches: print("absent") elif len(matches) != 1: print("conflict") else: item = matches[0] if host == "claude": actual = item.get("path") if item.get("source") == "directory" else None else: origin = item.get("marketplaceSource") actual = item.get("root") if isinstance(origin, dict) and origin.get("sourceType") == "local" else None if not isinstance(origin, dict) or not isinstance(origin.get("source"), str) or not Path(origin["source"]).is_absolute() or origin["source"] != actual: actual = None exact = isinstance(actual, str) and Path(actual).is_absolute() and actual == desired and str(Path(actual).resolve(strict=True)) == desired canonical_actual = isinstance(actual, str) and Path(actual).is_absolute() and str(Path(actual).resolve(strict=True)) == actual print("exact" if exact else "candidate:" + actual if canonical_actual else "conflict") except Exception: print("WARN: marketplace_registry_unavailable", file=sys.stderr) raise SystemExit(1) PY_REGISTRY } ensure_exact_marketplace_registration() { local host="$1" desired="" state="" desired="$(accepted_marketplace_source "$host")" || return 1 state="$(marketplace_registration_status "$host" "$desired")" || return 1 case "$state" in exact) return 0 ;; candidate:*) local candidate="${state#candidate:}" [[ "$(marketplace_registration_status "$host" "$candidate")" == exact ]] || { warn "marketplace_source_conflict"; return 1; } generation_adapter_receipt compare "$installed_runtime_generation" "$candidate" "$host" >/dev/null || return 1 [[ "$(marketplace_registration_status "$host" "$candidate")" == exact ]] || { warn "marketplace_source_conflict"; return 1; } ;; absent) run "$host" plugin marketplace add "$desired" || { warn "marketplace_registration_failed"; return 1; } state="$(marketplace_registration_status "$host" "$desired")" || return 1 [[ "$state" == exact ]] || { warn "marketplace_registration_unverified"; return 1; } ;; *) warn "marketplace_source_conflict"; return 1 ;; esac } install_claude() { if ! have claude; then warn "Claude CLI not found; skipped Claude plugin install." return 0 fi resolve_install_source_identity || return 1 log "== Claude Code plugin ==" ensure_exact_marketplace_registration claude || return 1 run claude plugin install "$plugin_name@$marketplace_name" || return 1 # `plugin install` already enables it, and `plugin enable` then exits # non-zero with "is already enabled". Treating that exit code as failure # made EVERY fresh install report "Claude install failed" and the whole # installer exit 1 — on the host most people arrive through — while the # plugin sat there installed and enabled. Ask the state, not the exit code. try claude plugin enable "$plugin_name@$marketplace_name" >/dev/null 2>&1 || true if ! claude plugin list 2>/dev/null | grep -A 3 -F "$plugin_name@$marketplace_name" | grep -qi "enabled"; then warn "plugin_enable_failed: '$plugin_name@$marketplace_name' is not enabled. Run: claude plugin enable $plugin_name@$marketplace_name" return 1 fi write_claude_commands || { warn "Claude global command refresh failed; bare /hep-* autocomplete will not persist into the next session." return 1 } register_claude_mcp || warn "Windows user-scope MCP registration failed; run the command printed above manually." prune_claude_plugin_cache || warn "Old plugin cache versions were left in place." log "Bundled MCP: local hephaestus-network Core (Cloud/Hub upstream stays behind Core)." ok=$((ok + 1)) } # The plugin's own .mcp.json points at the extensionless bash runner, and a # plugin manifest has no platform-conditional key to fix that (Claude Code # plugins reference: command/args/env plus ${CLAUDE_PLUGIN_ROOT}, nothing else). # Native Windows spawns stdio servers WITHOUT a shell, so that entry can never # start there and the whole local Workforce surface is missing — which is how a # Windows session ends up talking to the remote Hub with no menu projection. # # Register a user-scope server on Windows only. Claude Code namespaces plugin # MCP tools separately from user-scope ones, so this does not collide with the # POSIX plugin channel; on macOS/Linux the plugin entry is already correct and # this function is a no-op. register_claude_mcp() { agentlas_load_platform_helpers || return 1 agentlas_is_windows || return 0 local -a fields=() local field while IFS= read -r field; do [[ -n "$field" ]] && fields+=("$field") done < <(runtime_mcp_launch_fields) [[ "${#fields[@]}" -ge 2 ]] || return 1 try claude mcp remove hephaestus-network --scope user >/dev/null 2>&1 || true run claude mcp add --scope user hephaestus-network -- "${fields[@]}" || return 1 log "Registered user-scope hephaestus-network MCP for native Windows." } # Plugin cache versions accumulate without bound (measured: seven releases in # one cache). Keep the active version and one rollback target; anything older is # an orphan that only confuses `claude plugin` output. # PRD §4.22 — 런타임 홈은 버전별 디렉터리에 설치하고 링크만 바꾼다. 옛 버전을 정리하는 # 코드가 없어서 무한히 커졌다(실측 2026-08-23: 124개 버전 9.1GB, 버전당 162MB). # 현재 것과 되돌리기용 최근 몇 개만 남긴다. 살아 있는 호스트 설정은 전부 `current` 를 보므로 # 옛 버전 디렉터리에는 사용자 상태가 없다(2026-08-23 실측 확인). # 정리는 설치기와 업데이터 **양쪽**에 있어야 한다 — 한쪽에만 두면 다른 경로로 갱신한 # 머신은 계속 쌓인다. ONE_RUNTIME_KEEP="${AGENTLAS_RUNTIME_KEEP:-3}" prune_runtime_homes() { local runtime_root="$HOME/.agentlas/runtime" [[ -d "$runtime_root" ]] || return 0 local current_target="" if [[ -L "$runtime_root/current" ]]; then current_target="$(basename "$(readlink "$runtime_root/current")")" fi local -a versions=() local dir name while IFS= read -r dir; do name="$(basename "$dir")" # 숨김 스테이징/롤백 디렉터리와 현재 대상은 건드리지 않는다. [[ "$name" == .* ]] && continue [[ "$name" == "current" ]] && continue [[ -n "$current_target" && "$name" == "$current_target" ]] && continue versions+=("$name") done < <(find "$runtime_root" -mindepth 1 -maxdepth 1 -type d 2>/dev/null) local total="${#versions[@]}" local keep=$((ONE_RUNTIME_KEEP - 1)) # current 를 포함해 KEEP 개 [[ "$keep" -lt 0 ]] && keep=0 [[ "$total" -gt "$keep" ]] || return 0 local -a ordered=() while IFS= read -r name; do [[ -n "$name" ]] && ordered+=("$name") done < <(printf '%s\n' "${versions[@]+"${versions[@]}"}" | sort -t. -k1,1n -k2,2n -k3,3n) local removed=0 index=0 while [[ "$index" -lt $((total - keep)) ]]; do rm -rf "$runtime_root/${ordered[$index]}" 2>/dev/null && removed=$((removed + 1)) index=$((index + 1)) done [[ "$removed" -gt 0 ]] && log "Pruned $removed old runtime home version(s); kept the current one and $keep rollback target(s)." return 0 } prune_claude_plugin_cache() { local cache="$HOME/.claude/plugins/cache/$marketplace_name/$plugin_name" [[ -d "$cache" ]] || return 0 local keep="${version#v}" local -a stale=() local dir name while IFS= read -r dir; do name="$(basename "$dir")" [[ "$name" == "$keep" ]] && continue stale+=("$name") done < <(find "$cache" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort) # sort -V keeps the newest non-active version as the rollback target. local -a ordered=() while IFS= read -r name; do [[ -n "$name" ]] && ordered+=("$name") done < <(printf '%s\n' "${stale[@]+"${stale[@]}"}" | sort -t. -k1,1n -k2,2n -k3,3n) local total="${#ordered[@]}" [[ "$total" -gt 1 ]] || return 0 local index=0 local removed=0 while [[ "$index" -lt $((total - 1)) ]]; do rm -rf "$cache/${ordered[$index]}" 2>/dev/null && removed=$((removed + 1)) index=$((index + 1)) done [[ "$removed" -gt 0 ]] && log "Pruned $removed orphaned plugin cache version(s); kept $keep and ${ordered[$((total - 1))]}." return 0 } # Keep the user-global ~/.claude/commands copies in sync with this release. # Remove old entries first so stale symlinks from earlier installers do not # survive in the host app's command autocomplete cache. write_claude_commands() { ensure_downloaded_source || return 1 mkdir -p "$HOME/.claude/commands" local name src dest installed="" while IFS= read -r name; do [[ -n "$name" ]] || continue src="$source_dir/.claude/commands/$name" dest="$HOME/.claude/commands/$name" rm -f "$dest" cp "$src" "$dest" || return 1 installed+=" /${name%.md}" done < <(managed_command_files) [[ -n "$installed" ]] || { warn "No managed Claude commands were derived from the release."; return 1; } rm -f "$HOME/.claude/commands/hephaestus.md" "$HOME/.claude/commands/hephaests-network.md" \ "$HOME/.claude/commands/hephaestus-build.md" "$HOME/.claude/commands/hephaestus-network.md" \ "$HOME/.claude/commands/hephaestus-cloud.md" "$HOME/.claude/commands/hephaestus-search.md" \ "$HOME/.claude/commands/hephaestus-call.md" # Report what was actually installed. A hardcoded sentence here would have # kept claiming a complete set while two commands were missing from it. log "Refreshed Claude commands:$installed" } remove_codex_existing() { try codex plugin remove "$plugin_name@$marketplace_name" >/dev/null 2>&1 || true try codex plugin remove "$old_plugin_name@$marketplace_name" >/dev/null 2>&1 || true try codex plugin marketplace remove "$marketplace_name" >/dev/null 2>&1 || true rm -rf "${CODEX_HOME:-$HOME/.codex}/plugins/cache/$marketplace_name/$plugin_name" 2>/dev/null || true } # Codex 0.117+ removed custom prompts in favor of plugin skills. Keep the # prompt copier only for older Codex releases and remove only our managed dead # prompt files on current releases so the installer does not advertise a # command the host rejects. # # Feature detection, not version sniffing (PRD 2026-08-15 OS-9). Version # strings freeze, lie, or get reused; the capability itself can be asked for: # 1. `codex features list` — a stable `plugins` / `skill_search` flag means # the plugin-skills era (prompts gone). A table without them means the # prompts era. Measured on codex-cli 0.147.0 (2026-08-15). # 2. No feature table (older CLI): a `plugin` subcommand in --help. # 3. Only then the historical 0.117 version threshold, as a last resort. codex_plugin_skills_supported() { local table table="$(codex features list 2>/dev/null || true)" if [[ -n "$table" ]]; then printf '%s\n' "$table" | grep -Eq '^(plugins|skill_search)[[:space:]]+.*[[:space:]]true[[:space:]]*$' return $? fi if codex --help 2>/dev/null | grep -Eq '^[[:space:]]+plugin([[:space:]]|$)'; then return 0 fi ! codex_version_below_0_117 } # Historical fallback only — never the first question asked. codex_version_below_0_117() { local raw parsed major minor rest raw="$(codex --version 2>/dev/null || true)" parsed="$(printf '%s\n' "$raw" | sed -nE 's/^[^0-9]*([0-9]+)\.([0-9]+)(\.[0-9]+)?.*/\1.\2/p' | head -1)" [[ "$parsed" =~ ^[0-9]+\.[0-9]+$ ]] || return 1 major="${parsed%%.*}" rest="${parsed#*.}" minor="${rest%%.*}" [[ "$major" == "0" && "$minor" -lt 117 ]] } codex_custom_prompts_supported() { ! codex_plugin_skills_supported } prune_managed_codex_prompts() { local name while IFS= read -r name; do [[ -n "$name" ]] && rm -f "$HOME/.codex/prompts/$name" done < <(managed_command_files) } write_codex_prompts() { ensure_downloaded_source || return 1 if ! codex_custom_prompts_supported; then prune_managed_codex_prompts log 'Codex 0.117+ skill entrypoints: $hephaestus-build, $hephaestus-network, $hephaestus-cloud, $hephaestus-upload, $hephaestus-storm, $hephaestus-graph' return 0 fi local prompts_src="$source_dir/codex/prompts" [[ -d "$prompts_src" ]] || { warn "codex prompts not found: $prompts_src"; return 1; } mkdir -p "$HOME/.codex/prompts" local name installed="" while IFS= read -r name; do [[ -n "$name" ]] || continue rm -f "$HOME/.codex/prompts/$name" cp "$prompts_src/$name" "$HOME/.codex/prompts/$name" || return 1 installed+=" /prompts:${name%.md}" done < <(runtime_command_files "$prompts_src") [[ -n "$installed" ]] || { warn "No managed commands matched $prompts_src; Codex prompts were not refreshed."; return 1; } rm -f "$HOME/.codex/prompts/hephaestus.md" "$HOME/.codex/prompts/hephaests-network.md" \ "$HOME/.codex/prompts/hephaestus-build.md" "$HOME/.codex/prompts/hephaestus-network.md" \ "$HOME/.codex/prompts/hephaestus-cloud.md" "$HOME/.codex/prompts/hephaestus-search.md" \ "$HOME/.codex/prompts/hephaestus-call.md" log "Installed Codex custom prompts:$installed" } install_codex() { if ! have codex; then warn "Codex CLI not found; skipped Codex plugin install." return 0 fi resolve_install_source_identity || return 1 log "== Codex plugin ==" ensure_exact_marketplace_registration codex || return 1 run codex plugin add "$plugin_name@$marketplace_name" || return 1 write_codex_prompts || warn "Codex command-surface install failed; reinstall the Hephaestus plugin skills." register_codex_mcp || warn "Codex MCP registration failed; add it manually to ~/.codex/config.toml." ok=$((ok + 1)) } # Installed plugin RELEASE/provenance and launchers belong to their accepted # package. Never sweep or relabel old caches after a host registration failure. # The Codex plugin doesn't support MCP bundles, so register directly in config.toml. # Workforce must have one canonical MCP entrypoint. Remove the old direct # `agentlas` table (which bypassed Core) and replace the owned local table while # preserving every unrelated user table. The obsolete remote-MCP feature flag # is also removed because strict Codex versions reject it. register_codex_mcp() { local cfg="$HOME/.codex/config.toml" local preserved_env_table mkdir -p "$HOME/.codex" touch "$cfg" || return 1 if grep -q '^[[:space:]]*experimental_use_rmcp_client[[:space:]]*=' "$cfg"; then sed '/^[[:space:]]*experimental_use_rmcp_client[[:space:]]*=/d' "$cfg" > "$cfg.tmp" \ && mv "$cfg.tmp" "$cfg" || return 1 fi # The installer owns the command and args, but the operator owns model pins, # provider choices, and other server-launch environment policy. Preserve the # complete Codex-generated env subtable without reading or logging its values. # This keeps AGENTLAS_MODEL_ALLOCATION_POLICY_JSON stable across updates. preserved_env_table="$( awk ' /^[[:space:]]*\[mcp_servers\.("?hephaestus-network"?)\.env\][[:space:]]*$/ { capture=1 print next } capture && /^[[:space:]]*\[/ { capture=0 } capture { print } ' "$cfg" )" awk ' /^[[:space:]]*\[mcp_servers\.("?agentlas"?|"?hephaestus-network"?)(\.|\])[[:space:]]*/ { skip=1; next } skip && /^[[:space:]]*\[/ { skip=0 } !skip { print } ' "$cfg" > "$cfg.tmp" && mv "$cfg.tmp" "$cfg" || return 1 local codex_launch="" codex_launch="$(runtime_mcp_launch_render toml)" || { warn "Could not render the local Core MCP launch vector for Codex." return 1 } printf '\n[mcp_servers.hephaestus-network]\n%s\n' "$codex_launch" >> "$cfg" if [[ -n "$preserved_env_table" ]]; then printf '\n%s\n' "$preserved_env_table" >> "$cfg" fi log "Registered canonical local hephaestus-network MCP in $cfg" } write_gemini_fallback_command() { local command_dir="$HOME/.gemini/commands" mkdir -p "$command_dir" local name for name in hep-build.toml hep-network.toml hep-local.toml hep-cloud.toml hep-hub.toml hep-search.toml hep-browser.toml hep-call.toml hep-upload.toml hep-storm.toml hep-graph.toml agentlas.toml; do rm -f "$command_dir/$name" cp "$source_dir/gemini/extension/commands/$name" "$command_dir/$name" || return 1 done rm -f "$command_dir/hephaestus.toml" "$command_dir/hephaests-network.toml" \ "$command_dir/hephaestus-build.toml" "$command_dir/hephaestus-network.toml" \ "$command_dir/hephaestus-cloud.toml" "$command_dir/hephaestus-search.toml" \ "$command_dir/hephaestus-call.toml" log "Installed Gemini fallback commands: /hep-build, /hep-network, /hep-local, /hep-cloud, /hep-hub, /hep-search, /hep-browser, /hep-call, /hep-upload, /hep-storm, /hep-graph" } install_gemini() { if ! have gemini; then warn "Gemini CLI not found; skipped Gemini extension install." return 0 fi log "== Gemini CLI extension and command ==" try gemini extensions uninstall hephaestus >/dev/null 2>&1 || true ensure_downloaded_source || return 1 local gemini_extension_dir="$source_dir/gemini/extension" if [[ ! -f "$gemini_extension_dir/gemini-extension.json" ]]; then warn "Gemini extension manifest not found: $gemini_extension_dir/gemini-extension.json" return 1 fi chmod +x "$gemini_extension_dir/bin/hephaestus" 2>/dev/null || true if [[ -z "${HEPHAESTUS_SOURCE_DIR:-}" ]]; then local stable_gemini_source="$HOME/.gemini/hephaestus-extension-source" rm -rf "$stable_gemini_source" mkdir -p "$stable_gemini_source" copy_tree_without_python_cache "$gemini_extension_dir" "$stable_gemini_source" || return 1 gemini_extension_dir="$stable_gemini_source" fi run_yes gemini extensions install "$gemini_extension_dir" --consent --skip-settings || return 1 write_gemini_fallback_command || return 1 ok=$((ok + 1)) } antigravity_present() { [[ -d "$HOME/.gemini/antigravity" ]] && return 0 # The "Antigravity IDE" variant uses a separate data directory (~/.gemini/antigravity-ide). [[ -d "$HOME/.gemini/antigravity-ide" ]] && return 0 # Current Antigravity installs leave this CLI state directory even before a # global_workflows directory exists. Treat it as a presence marker, but keep # workflow installation in the documented antigravity/antigravity-ide roots. [[ -d "$HOME/.gemini/antigravity-cli" ]] && return 0 [[ -n "${HEPHAESTUS_FORCE_ANTIGRAVITY:-}" ]] && return 0 ls -d /Applications/Antigravity*.app >/dev/null 2>&1 && return 0 return 1 } install_antigravity() { if ! antigravity_present; then warn "Antigravity not detected; skipped Antigravity workflow install." return 0 fi log "== Antigravity workflow & plugins ==" ensure_downloaded_source || return 1 # Install into both data directory variants — so the same command set shows up whichever app you use. local installed=0 local data_dir for data_dir in "$HOME/.gemini/antigravity" "$HOME/.gemini/antigravity-ide"; do # Install only into a data directory that exists, but create the default path if neither exists. if [[ -d "$data_dir" || ( "$installed" -eq 0 && "$data_dir" == "$HOME/.gemini/antigravity" ) ]]; then local global_dir="$data_dir/global_workflows" mkdir -p "$global_dir" local name workflows="" while IFS= read -r name; do [[ -n "$name" ]] || continue rm -f "$global_dir/$name" cp "$source_dir/antigravity/workflows/$name" "$global_dir/$name" || return 1 workflows+=" /${name%.md}" done < <(runtime_command_files "$source_dir/antigravity/workflows") [[ -n "$workflows" ]] || { warn "No managed commands matched $source_dir/antigravity/workflows."; return 1; } rm -f "$global_dir/hephaestus.md" "$global_dir/hephaests-network.md" \ "$global_dir/hephaestus-build.md" "$global_dir/hephaestus-network.md" \ "$global_dir/hephaestus-cloud.md" "$global_dir/hephaestus-search.md" \ "$global_dir/hephaestus-call.md" log "Installed Antigravity global workflows:$workflows" installed=$((installed + 1)) fi done # Install Agentlas OS plugin & skills into ~/.gemini/config/plugins/agentlas-os local plugin_target="$HOME/.gemini/config/plugins/agentlas-os" mkdir -p "$plugin_target/skills" cat > "$plugin_target/plugin.json" <&1 )"; then warn "Local memory hook install failed. Error was:" printf '%s\n' "$hook_output" | tail -12 >&2 return 1 fi log "Installed merge-safe local memory hooks for detected Antigravity, Grok, and OpenCode hosts." } # OpenClaw loads AgentSkills from ~/.openclaw/skills (and ~/.agents/skills); # user-invocable skills surface as slash commands via /skill. install_openclaw() { if ! have openclaw && [[ ! -d "$HOME/.openclaw" ]]; then warn "OpenClaw not detected; skipped OpenClaw skill install." return 0 fi log "== OpenClaw skill ==" ensure_downloaded_source || return 1 local name skill_src mkdir -p "$HOME/.openclaw/skills" for name in "${managed_skill_names[@]}"; do skill_src="$source_dir/openclaw/skills/$name" [[ -d "$skill_src" ]] || continue if have openclaw && openclaw skills install "$skill_src" --global >/dev/null 2>&1; then log "Installed OpenClaw skill via: openclaw skills install --global ($name)" else rm -rf "$HOME/.openclaw/skills/$name" copy_tree_without_python_cache "$skill_src" "$HOME/.openclaw/skills/$name" || return 1 fi done log "Installed available OpenClaw skills from the managed AgentSkills set" install_openclaw_hook || warn "OpenClaw memory hook install failed; skills remain installed." ok=$((ok + 1)) } # OpenClaw has no session-end event, so the One checkpoint runs on the commands # that close a session. Copying is the fallback when the CLI is unavailable. install_openclaw_hook() { local hook_src="$source_dir/openclaw/hooks/agentlas-one" [[ -d "$hook_src" ]] || { warn "OpenClaw hook source missing: $hook_src"; return 1; } if have openclaw && openclaw hooks install "$hook_src" >/dev/null 2>&1; then log "Installed OpenClaw hook via: openclaw hooks install (agentlas-one)" return 0 fi mkdir -p "$HOME/.openclaw/hooks" rm -rf "$HOME/.openclaw/hooks/agentlas-one" copy_tree_without_python_cache "$hook_src" "$HOME/.openclaw/hooks/agentlas-one" || return 1 log "Installed OpenClaw hook by copy: agentlas-one" } # Hermes Agent (Nous Research) reads AgentSkills from ~/.hermes/skills. install_hermes() { if ! have hermes && [[ ! -d "$HOME/.hermes" ]]; then warn "Hermes Agent not detected; skipped Hermes skill install." return 0 fi log "== Hermes Agent skill ==" ensure_downloaded_source || return 1 mkdir -p "$HOME/.hermes/skills" local name for name in "${managed_skill_names[@]}"; do rm -rf "$HOME/.hermes/skills/$name" copy_tree_without_python_cache "$source_dir/skills/$name" "$HOME/.hermes/skills/$name" || return 1 done log "Installed Hermes skills: ${managed_skill_names[*]} (MCP: see hermes/README.md)" ok=$((ok + 1)) } # Kimi Code CLI (Moonshot AI) reads AgentSkills from ~/.kimi/skills; each # folder's `name:` field (already "hep-" or "agentlas-", not a # "hephaestus-" prefix) is what Kimi's own picker shows, so both spellings # surface natively without any alias trick. install_kimi() { if ! have kimi && [[ ! -d "$HOME/.kimi" ]]; then warn "Kimi Code CLI not detected; skipped Kimi skill install." return 0 fi log "== Kimi Code CLI skills ==" ensure_downloaded_source || return 1 local skills_src="$source_dir/kimi/skills" [[ -d "$skills_src" ]] || { warn "release is missing kimi/skills; cannot install Kimi skills."; return 1; } mkdir -p "$HOME/.kimi/skills" local dir name installed=0 for dir in "$skills_src"/*; do [[ -d "$dir" ]] || continue name="$(basename "$dir")" rm -rf "$HOME/.kimi/skills/$name" copy_tree_without_python_cache "$dir" "$HOME/.kimi/skills/$name" || return 1 installed=$((installed + 1)) done [[ "$installed" -gt 0 ]] || { warn "No managed skills matched $skills_src."; return 1; } log "Installed $installed Kimi skills (hep-* and agentlas-* both native) to ~/.kimi/skills." ok=$((ok + 1)) } # goose (Block) reads the project AGENTS.md natively; its only global surface # is the MCP extension table in ~/.config/goose/config.yaml. YAML cannot be # merged safely without extra dependencies, so only a missing config is # created; an existing config is never rewritten. install_goose() { if ! have goose && [[ ! -d "$HOME/.config/goose" ]]; then warn "goose not detected; skipped goose MCP registration." return 0 fi log "== goose MCP ==" local cfg="$HOME/.config/goose/config.yaml" if [[ -f "$cfg" ]]; then if grep -q 'hephaestus-network' "$cfg"; then log "goose config already references hephaestus-network; left $cfg untouched." else warn "goose config exists; add the hephaestus-network extension manually (see goose/README.md)." fi else mkdir -p "$(dirname "$cfg")" local goose_launch="" goose_launch="$(runtime_mcp_launch_render yaml | sed 's/^/ /')" || { warn "Could not render the local Core MCP launch vector for goose." return 1 } cat > "$cfg" </hooks/hooks.json and # uses the same hook manifest shape as Claude Code. Write only our own plugin # directory so unrelated plugins stay untouched. install_goose_hook() { local src="$source_dir/goose/plugins/agentlas-one" [[ -d "$src" ]] || { warn "goose hook source missing: $src"; return 1; } local dest="$HOME/.agents/plugins/agentlas-one" mkdir -p "$(dirname "$dest")" rm -rf "$dest" copy_tree_without_python_cache "$src" "$dest" || return 1 log "Installed goose SessionEnd hook: ~/.agents/plugins/agentlas-one" } # GitHub Copilot CLI reads the project AGENTS.md natively; its only global # surface is ~/.copilot/mcp-config.json. install_copilot_cli() { if ! have copilot && [[ ! -d "$HOME/.copilot" ]]; then warn "Copilot CLI not detected; skipped Copilot CLI MCP registration." return 0 fi log "== Copilot CLI MCP ==" local cfg="$HOME/.copilot/mcp-config.json" local py="" py="$(resolve_python_cmd || true)" [[ -n "$py" ]] || { warn "python3 not found; add local hephaestus-network to $cfg manually."; return 1; } mkdir -p "$(dirname "$cfg")" AGENTLAS_LOCAL_MCP_ENTRY="$(runtime_mcp_launch_render json)" \ run_resolved_python "$py" - "$cfg" <<'PY' || return 1 import json, os, sys path = sys.argv[1] entry = json.loads(os.environ["AGENTLAS_LOCAL_MCP_ENTRY"]) try: with open(path, encoding="utf-8") as f: data = json.load(f) except FileNotFoundError: data = {} except ValueError as exc: raise SystemExit(f"refusing to overwrite invalid Copilot MCP config {path}: {exc}") servers = data.setdefault("mcpServers", {}) servers.pop("agentlas", None) servers["hephaestus-network"] = { "type": "local", **entry, "tools": ["*"], } with open(path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2) f.write("\n") PY log "Registered canonical local hephaestus-network MCP in $cfg (Copilot CLI reads the project AGENTS.md natively)." ok=$((ok + 1)) } # Hephaestus Network 2.0: create or migrate ~/.agentlas/networking on every # install/upgrade (idempotent; indexes only registered paths, never the home # folder). bootstrap_networking() { local py="" py="$(resolve_python_cmd || true)" if [[ -z "$py" ]]; then warn "python3 not found; skipped Hephaestus Network init. Install Python 3.9+ and run: hephaestus network init" return 0 fi if ! ensure_downloaded_source; then warn "Hephaestus Network init skipped: could not download the source archive (curl/tar). Run later: hephaestus network init" return 0 fi log "== Hephaestus Network (global routing structure) ==" local init_output if ! init_output="$(PYTHONUTF8=1 PYTHONIOENCODING=utf-8 PYTHONPATH="$(installer_pythonpath "$source_dir")" run_resolved_python "$py" -m agentlas_cloud network init 2>&1)"; then warn "Hephaestus Network init failed. Error was:" printf '%s\n' "$init_output" | tail -5 >&2 warn "Retry manually: PYTHONPATH= $py -m agentlas_cloud network init" return 1 fi PYTHONUTF8=1 PYTHONIOENCODING=utf-8 PYTHONPATH="$(installer_pythonpath "$source_dir")" run_resolved_python "$py" -m agentlas_cloud network reindex >/dev/null 2>&1 || true log "Initialized ~/.agentlas/networking (cards, policies, ledgers, local memory map)." } prune_legacy_public_surfaces() { local stale_md=( hephaestus.md hephaests-network.md agentlas-auto-activation.md agentlas-core-engine-meta-agent.md agentlas-packaging.md agentlas-security-scan.md clarify-question-loop.md mode-classification.md self-evolving-single-agent.md skill-lifecycle-promotion.md team-builder-packaging.md ) local name for name in "${stale_md[@]}"; do rm -f "$HOME/.claude/commands/$name" rm -f "$HOME/.codex/prompts/$name" rm -f "$HOME/.cursor/commands/$name" rm -f "$HOME/.config/opencode/commands/$name" rm -f "$HOME/.gemini/antigravity/global_workflows/$name" rm -f "$HOME/.gemini/antigravity-ide/global_workflows/$name" done rm -f "$HOME/.gemini/commands/hephaestus.toml" "$HOME/.gemini/commands/hephaests-network.toml" rm -f "$HOME/.gemini/commands/agentlas-auto-activation.toml" \ "$HOME/.gemini/commands/agentlas-core-engine-meta-agent.toml" \ "$HOME/.gemini/commands/agentlas-packaging.toml" \ "$HOME/.gemini/commands/agentlas-security-scan.toml" \ "$HOME/.gemini/commands/clarify-question-loop.toml" \ "$HOME/.gemini/commands/mode-classification.toml" \ "$HOME/.gemini/commands/self-evolving-single-agent.toml" \ "$HOME/.gemini/commands/skill-lifecycle-promotion.toml" \ "$HOME/.gemini/commands/team-builder-packaging.toml" find "$HOME/.claude/plugins/cache/$marketplace_name/$plugin_name" -maxdepth 1 -type d \ \( -name '0-7-4' -o -name '0.7.4' \) -exec rm -rf {} + 2>/dev/null || true find "${CODEX_HOME:-$HOME/.codex}/plugins/cache/$marketplace_name/$plugin_name" -maxdepth 1 -type d \ \( -name '0-7-4' -o -name '0.7.4' \) -exec rm -rf {} + 2>/dev/null || true log "Pruned legacy visible chat command files and stale 0.7.4 cache folders." } main() { resolve_install_source_identity || exit 1 log "Hephaestus one-touch install/update" log "repo: $repo" log "ref: $version" log "mode: force refresh=${force}" preflight_git || exit 1 install_runtime_home || { warn "Runtime home install failed."; failed=$((failed + 1)); } load_managed_skill_names || { warn "Command registry skill selection failed; using fallback skills."; } install_agents_skills || { warn "Universal ~/.agents/skills install failed."; failed=$((failed + 1)); } install_claude || { warn "Claude install failed."; failed=$((failed + 1)); } install_codex || { warn "Codex install failed."; failed=$((failed + 1)); } install_gemini || { warn "Gemini install failed."; failed=$((failed + 1)); } install_antigravity || { warn "Antigravity install failed."; failed=$((failed + 1)); } install_cursor || { warn "Cursor install failed."; failed=$((failed + 1)); } install_opencode || { warn "OpenCode install failed."; failed=$((failed + 1)); } install_memory_hooks || { warn "Local ontology memory hook install failed."; failed=$((failed + 1)); } install_openclaw || { warn "OpenClaw install failed."; failed=$((failed + 1)); } install_hermes || { warn "Hermes install failed."; failed=$((failed + 1)); } install_kimi || { warn "Kimi install failed."; failed=$((failed + 1)); } install_goose || { warn "goose install failed."; failed=$((failed + 1)); } install_copilot_cli || { warn "Copilot CLI install failed."; failed=$((failed + 1)); } bootstrap_networking || warn "Hephaestus Network init failed; run 'hephaestus network init' manually." if [[ "${HEPHAESTUS_INSTALL_GLOBAL_ROUTER:-0}" == "1" ]]; then "$HOME/.agentlas/runtime/current/bin/hephaestus" global install || warn "Global router prompt install failed; run 'hep-global install' manually." fi "$HOME/.agentlas/runtime/current/bin/hephaestus" hep-update --remove-service >/dev/null 2>&1 \ || warn "Legacy periodic update service cleanup was deferred; the next /hep-* command will retry it." prune_legacy_public_surfaces log "" log "Installed/updated runtimes: $ok" log "Failed runtimes: $failed" log "" log "Public chat surface: core external commands are installed or refreshed; Claude/Codex also get the Telegram connect helper; Agentlas native surfaces use plain language." log "Local memory recall: Claude/Codex hooks, Antigravity PreInvocation, and OpenCode system injection are dynamic; Grok uses passive cache refresh plus its static AGENTS.md pointer." log "Automatic updates: Desktop startup and /hep-* commands launch a verified, rate-limited background update without delaying the current task." log "Restart open Claude Code, Codex, Gemini, Antigravity, Cursor, OpenCode, OpenClaw, Hermes, goose, Kimi, and Copilot CLI apps." log "Then use:" log " Agentlas: describe the task in plain language; native tools choose the path" log " Claude Code: /reload-plugins, then /hep-build, /hep-network, /hep-local, /hep-cloud, /hep-hub, /hep-storm, /hep-search, /hep-browser, /hep-call, /hep-upload, /hep-graph, /hep-connect" log ' Codex: $hephaestus-build, $hephaestus-network, $hephaestus-cloud, $hephaestus-upload, $hephaestus-storm, $hephaestus-graph; use plain language for local/hub/search/browser/call/connect' log " Gemini CLI: /extensions list or /commands list, then /hep-build, /hep-network, /hep-local, /hep-cloud, /hep-hub, /hep-storm, /hep-search, /hep-browser, /hep-call, /hep-upload, /hep-graph" log " Antigravity: reopen the workspace, then /hep-build, /hep-network, /hep-local, /hep-cloud, /hep-hub, /hep-storm, /hep-search, /hep-browser, /hep-call, /hep-upload, /hep-graph" log " Cursor: /hep-build, /hep-network, /hep-local, /hep-cloud, /hep-hub, /hep-storm, /hep-search, /hep-browser, /hep-call, /hep-upload, /hep-graph" log " OpenCode: /hep-build, /hep-network, /hep-local, /hep-cloud, /hep-hub, /hep-storm, /hep-search, /hep-browser, /hep-call, /hep-upload, /hep-graph" log " OpenClaw: /skill hephaestus-upload or /skill hephaestus-network " log " Hermes: hephaestus-upload/hephaestus-network/hephaestus-graph skills (+ MCP, see hermes/README.md)" log " goose/Copilot CLI: project AGENTS.md is read natively; use the hephaestus-network MCP tools" log " Shell/debug: ontology , hep-build \"\", hep-network \"\", hep-local \"\", hep-cloud \"\", hep-hub \"\", hep-search \"\", hep-browser , hep-call \"agent-a,agent-b\" \"\", hep-upload , hep-global install, or hep-storm \"\" --background" log " Ollama/Gemma/DeepSeek local models: use the local MCP entrypoint 'hephaestus mcp serve'" log "" log "MCP topology: hephaestus-network is the only host-visible Workforce entrypoint; Cloud/Hub upstream stays inside Agentlas OS Core." log "Try a plain-language prompt in any runtime, e.g.:" log " \"agentlas에서 ASO 도와주는 에이전트 찾아줘\" / \"find an agentlas agent for app store reviews\"" if [[ "$ok" -eq 0 || "$failed" -gt 0 ]]; then exit 1 fi } if [[ "${AGENTLAS_INSTALLER_LIBRARY_ONLY:-0}" == "1" ]]; then return 0 2>/dev/null || exit 0 fi # Allow `source` for contract tests without running the installer. # # `curl ... | bash` feeds the script on stdin, so BASH_SOURCE is an EMPTY array. # With `set -u` (line 2) a bare ${BASH_SOURCE[0]} then aborts with # "BASH_SOURCE[0]: unbound variable" before main ever runs — the documented # install command died on this line (issue #16). # Defaulting to "$0" keeps both paths correct: # piped/executed -> BASH_SOURCE unset or == $0 -> run main # sourced -> BASH_SOURCE[0] is this file, $0 is the caller -> skip main if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then main "$@" fi