#!/usr/bin/env python3 """UClaude Updater — automatic Claude Code patch updater. Usage: sudo python3 uclaude_updater.py # Check and update if new version available sudo python3 uclaude_updater.py --check # Only check, don't install sudo python3 uclaude_updater.py --force # Update even if version matches sudo python3 uclaude_updater.py --settings-only # Only patch settings, don't touch cli.js """ import argparse import json import os import re import shutil import subprocess import sys import time # ============================================================ # Platform detection # ============================================================ IS_WINDOWS = sys.platform == "win32" IS_MACOS = sys.platform == "darwin" try: import pwd HAS_PWD = True except ImportError: HAS_PWD = False SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) # claude/ REPO_ROOT = os.path.dirname(SCRIPT_DIR) # unlimitedcoding/ # ANSI colors G = "\033[92m" # green Y = "\033[93m" # yellow R = "\033[91m" # red W = "\033[97m" # white/bold D = "\033[0m" # default # ============================================================ # Helpers # ============================================================ def is_admin(): if IS_WINDOWS: try: import ctypes return ctypes.windll.shell32.IsUserAnAdmin() != 0 except Exception: return False return os.geteuid() == 0 def safe_chown(path, uid, gid): if not IS_WINDOWS and is_admin(): os.chown(path, uid, gid) def safe_chmod(path, mode): if not IS_WINDOWS: os.chmod(path, mode) def eprint(*args, **kwargs): print(*args, file=sys.stderr, **kwargs) def run_cmd(cmd, **kwargs): """run_cmd wrapper: uses shell=True on Windows for .cmd/.bat commands (npm, claude, etc.).""" if IS_WINDOWS: kwargs.setdefault("shell", True) return subprocess.run(cmd, **kwargs) # ============================================================ # Node.js check and auto-install # ============================================================ MIN_NODE_VERSION = (18, 0, 0) # fallback; dynamically updated from npm registry def get_required_node_version(): """Detect the Node.js version required by Claude Code from npm registry. Returns the minimum major version as integer (e.g. 24), or falls back to MIN_NODE_VERSION[0] if detection fails. """ try: import urllib.request req = urllib.request.Request( "https://registry.npmjs.org/@anthropic-ai/claude-code/latest", headers={"Accept": "application/json"}, ) with urllib.request.urlopen(req, timeout=10) as resp: data = json.loads(resp.read().decode("utf-8")) engines = data.get("engines", {}) node_req = engines.get("node", "") # Parse ">=18.0.0" or "^24.0.0" etc → extract first number m = re.search(r"(\d+)", node_req) if m: return int(m.group(1)) except Exception: pass return MIN_NODE_VERSION[0] def get_node_version(): """Get installed Node.js version as tuple, or None.""" try: result = run_cmd( ["node", "--version"], capture_output=True, text=True, timeout=10, ) m = re.match(r"v?(\d+)\.(\d+)\.(\d+)", result.stdout.strip()) if m: return (int(m.group(1)), int(m.group(2)), int(m.group(3))) except (FileNotFoundError, subprocess.TimeoutExpired): pass return None def install_node(): """Auto-install Node.js using the official nodesource setup (Linux) or brew (macOS). Dynamically detects required major version from npm registry. """ required_major = get_required_node_version() print(f" {Y}Node.js v{required_major}+ required.{D}") if IS_WINDOWS: print(f" {R}Please install Node.js manually: https://nodejs.org/{D}") print(f" Or run: winget install OpenJS.NodeJS") return False if IS_MACOS: print(f" Installing Node.js via Homebrew...") try: result = run_cmd( ["brew", "install", "node"], timeout=120, capture_output=True, text=True, ) if result.returncode == 0: ver = get_node_version() if ver and ver[0] >= required_major: print(f" {G}Node.js v{'.'.join(map(str, ver))} installed{D}") return True except FileNotFoundError: pass eprint(f" {R}Install Homebrew first: https://brew.sh/ then: brew install node{D}") return False # Linux — nodesource with dynamic major version print(f" Installing Node.js v{required_major} via nodesource...") try: # Remove old nodesource list if present (prevents version conflicts) for old_list in ["/etc/apt/sources.list.d/nodesource.list"]: if os.path.isfile(old_list): os.remove(old_list) result = run_cmd( ["bash", "-c", f"curl -fsSL https://deb.nodesource.com/setup_{required_major}.x | bash - && apt-get remove -y nodejs || true && apt-get install -y nodejs"], timeout=180, capture_output=True, text=True, ) if result.returncode == 0: ver = get_node_version() if ver and ver[0] >= required_major: print(f" {G}Node.js v{'.'.join(map(str, ver))} installed{D}") return True elif ver: eprint(f" {Y}nodesource installed v{'.'.join(map(str, ver))} but need v{required_major}+{D}") # Try dnf/yum fallback for RHEL/Fedora for pkg_mgr in ["dnf", "yum"]: if shutil.which(pkg_mgr): result = run_cmd( ["bash", "-c", f"curl -fsSL https://rpm.nodesource.com/setup_{required_major}.x | bash - && {pkg_mgr} install -y nodejs"], timeout=180, capture_output=True, text=True, ) if result.returncode == 0: ver = get_node_version() if ver and ver[0] >= required_major: print(f" {G}Node.js v{'.'.join(map(str, ver))} installed{D}") return True elif ver: eprint(f" {Y}Installed v{'.'.join(map(str, ver))} but need v{required_major}+{D}") break eprint(f" {R}Auto-install failed. Install Node.js v{required_major}+ manually: https://nodejs.org/{D}") if result.stderr: eprint(f" {result.stderr.strip()[:200]}") return False except Exception as e: eprint(f" {R}Auto-install error: {e}{D}") return False def ensure_node(): """Check Node.js version, auto-install/update if needed. Returns True if OK.""" ver = get_node_version() required_major = get_required_node_version() if ver is None: print(f" {Y}Node.js not found.{D}") if is_admin(): ok = install_node() if ok: # Re-verify after install — PATH may now point to new binary ver = get_node_version() if ver and ver[0] >= required_major: return True eprint(f" {R}Node.js still not available after install. Reopen shell or check PATH.{D}") return False return False else: eprint(f" {R}Install Node.js v{required_major}+: https://nodejs.org/{D}") return False if ver[0] < required_major: print(f" {Y}Node.js v{'.'.join(map(str, ver))} found, need v{required_major}+{D}") if is_admin(): ok = install_node() if ok: # Re-verify after upgrade — PATH may now point to new binary ver = get_node_version() if ver and ver[0] >= required_major: return True eprint(f" {R}Node.js version still insufficient after upgrade. Reopen shell or check PATH.{D}") return False return False else: eprint(f" {R}Update Node.js: https://nodejs.org/{D}") return False return True # ============================================================ # Claude Code auto-install # ============================================================ NPM_REGISTRY = "https://npm.sensey24.ru/" def set_npm_registry(): """Configure npm to use our patched registry for @anthropic-ai scope.""" try: run_cmd( ["npm", "config", "set", "@anthropic-ai:registry", NPM_REGISTRY], capture_output=True, text=True, timeout=10, ) except Exception: pass def ensure_claude_code(target_version=None): """Install or update Claude Code via npm. Returns True if OK. If target_version is set and the installed version doesn't match, reinstall to the exact version so cli.js patch is compatible. """ all_paths = find_all_cli_js() cli_js = all_paths[0] if all_paths else None # If already installed, check version compatibility if cli_js and target_version: installed_ver, _ = get_installed_version() if installed_ver and ver_tuple(installed_ver) >= ver_tuple(target_version): return True # Version mismatch — need to update npm package to match patched cli.js print(f" {Y}Claude Code {installed_ver} installed, need {target_version} for patch compatibility{D}") print(f" Updating npm package to v{target_version}...") pkg = f"@anthropic-ai/claude-code@{target_version}" elif cli_js: return True else: print(f" {Y}Claude Code not found. Installing via npm...{D}") pkg = "@anthropic-ai/claude-code" + (f"@{target_version}" if target_version else "") # Configure registry for @anthropic-ai scope set_npm_registry() print(f" Using registry: {NPM_REGISTRY}") # If a legacy cli.js is present, npm install -g often refuses to overwrite # cleanly (cached metadata, locked .bin symlinks) — uninstall first so the # subsequent install lays down the SEA layout cleanly. legacy_cli_js = [p for p in find_all_cli_js() if p.endswith(".js")] if legacy_cli_js: print(f" {Y}Removing legacy cli.js install before SEA install " f"({len(legacy_cli_js)} location(s))...{D}") try: run_cmd( ["npm", "uninstall", "-g", "@anthropic-ai/claude-code"], capture_output=True, text=True, timeout=120, ) except Exception as e: eprint(f" {Y}npm uninstall failed (continuing anyway): {e}{D}") try: result = run_cmd( ["npm", "install", "-g", pkg, "--registry", NPM_REGISTRY, "--force"], capture_output=True, text=True, timeout=300, ) if result.returncode == 0: found = find_all_cli_js() if found: new_ver, _ = get_installed_version() print(f" {G}Claude Code {new_ver or ''} installed{D}") return True eprint(f" {R}npm install failed{D}") if result.stderr: eprint(f" {result.stderr.strip()[:300]}") return False except FileNotFoundError: eprint(f" {R}npm not found. Install Node.js first.{D}") return False except subprocess.TimeoutExpired: eprint(f" {R}npm install timed out{D}") return False except Exception as e: eprint(f" {R}Error: {e}{D}") return False # ============================================================ # Version detection # ============================================================ def find_claude_artifact(): """Find the installed Claude Code primary artifact path. Returns the path to either: - cli.js (legacy ≤2.1.113 installs) - bin/claude.exe (SEA installs ≥2.1.114 — native binary) Prefers SEA layout when present. Returns None if nothing found. Note: SEA installs may end up nested as: /@anthropic-ai/claude-code/node_modules/@anthropic-ai/claude-code/bin/claude.exe because npm resolves the platform-specific dep that way. We resolve `which claude` → realpath() to find the actual binary regardless of how deep the nesting is. """ # 1. Resolve via which claude → realpath (handles arbitrary nesting) try: result = subprocess.run(["which", "claude"], capture_output=True, text=True, timeout=5) if result.returncode == 0: real = os.path.realpath(result.stdout.strip()) if os.path.basename(real) in ("cli.js", "claude.exe", "claude"): return real except Exception: pass # 2. Static well-known paths — try BOTH cli.js and SEA layouts js_candidates = [] sea_candidates = [] if IS_WINDOWS: for env_key in ("APPDATA", "LOCALAPPDATA", "PROGRAMFILES"): base = os.environ.get(env_key, "") if base: pkg = os.path.join(base, "npm", "node_modules", "@anthropic-ai", "claude-code") js_candidates.append(os.path.join(pkg, "cli.js")) sea_candidates.append(os.path.join(pkg, "bin", "claude.exe")) else: for prefix in ("/usr/lib", "/usr/local/lib", "/opt/homebrew/lib"): pkg = os.path.join(prefix, "node_modules", "@anthropic-ai", "claude-code") js_candidates.append(os.path.join(pkg, "cli.js")) sea_candidates.append(os.path.join(pkg, "bin", "claude.exe")) # Nested layout (npm install of SEA wrapper package) nested_pkg = os.path.join(pkg, "node_modules", "@anthropic-ai", "claude-code") js_candidates.append(os.path.join(nested_pkg, "cli.js")) sea_candidates.append(os.path.join(nested_pkg, "bin", "claude.exe")) # 3. npm root -g try: r = subprocess.run(["npm", "root", "-g"], capture_output=True, text=True, timeout=10) if r.returncode == 0: npm_global = r.stdout.strip() pkg = os.path.join(npm_global, "@anthropic-ai", "claude-code") js_candidates.insert(0, os.path.join(pkg, "cli.js")) sea_candidates.insert(0, os.path.join(pkg, "bin", "claude.exe")) nested_pkg = os.path.join(pkg, "node_modules", "@anthropic-ai", "claude-code") js_candidates.insert(0, os.path.join(nested_pkg, "cli.js")) sea_candidates.insert(0, os.path.join(nested_pkg, "bin", "claude.exe")) except Exception: pass # SEA preferred (newer install layout) for path in sea_candidates: if os.path.isfile(path): return path for path in js_candidates: if os.path.isfile(path): return path return None def find_cli_js(): """Backward-compat alias. Returns artifact path (cli.js OR claude.exe).""" return find_claude_artifact() def find_all_cli_js(): """Find ALL installed Claude Code artifact paths (cli.js OR claude.exe). Returns list of paths to cli.js (legacy) AND/OR bin/claude.exe (SEA), across multiple install locations (npm global, /usr/lib, NVM, nested). """ candidates = set() def _add_pkg(pkg): """Register both legacy and SEA layouts under one package root.""" candidates.add(os.path.join(pkg, "cli.js")) candidates.add(os.path.join(pkg, "bin", "claude.exe")) # Nested install (npm SEA wrapper) nested = os.path.join(pkg, "node_modules", "@anthropic-ai", "claude-code") candidates.add(os.path.join(nested, "cli.js")) candidates.add(os.path.join(nested, "bin", "claude.exe")) if IS_WINDOWS: for env_key in ("APPDATA", "LOCALAPPDATA", "PROGRAMFILES"): base = os.environ.get(env_key, "") if base: _add_pkg(os.path.join(base, "npm", "node_modules", "@anthropic-ai", "claude-code")) else: # Static well-known paths for prefix in ("/usr/lib", "/usr/local/lib", "/opt/homebrew/lib"): _add_pkg(os.path.join(prefix, "node_modules", "@anthropic-ai", "claude-code")) # npm root -g (primary install path) try: r = subprocess.run(["npm", "root", "-g"], capture_output=True, text=True, timeout=10) if r.returncode == 0: _add_pkg(os.path.join(r.stdout.strip(), "@anthropic-ai", "claude-code")) except Exception: pass # Resolve `which claude` → follow symlinks → find artifact try: r = subprocess.run(["which", "claude"], capture_output=True, text=True, timeout=5) if r.returncode == 0: claude_bin = os.path.realpath(r.stdout.strip()) bn = os.path.basename(claude_bin) if bn in ("cli.js", "claude.exe", "claude"): candidates.add(claude_bin) else: # which claude points to .bin/claude wrapper nm = os.path.dirname(os.path.dirname(claude_bin)) # node_modules/ _add_pkg(os.path.join(nm, "@anthropic-ai", "claude-code")) except Exception: pass # NVM installs: /root/.nvm, /home/*/.nvm nvm_bases = ["/root/.nvm"] if os.path.isdir("/home"): for user in os.listdir("/home"): nvm_bases.append(f"/home/{user}/.nvm") for nvm_base in nvm_bases: versions_dir = os.path.join(nvm_base, "versions", "node") if os.path.isdir(versions_dir): for node_ver in os.listdir(versions_dir): _add_pkg(os.path.join(versions_dir, node_ver, "lib", "node_modules", "@anthropic-ai", "claude-code")) return [p for p in candidates if os.path.isfile(p)] def get_installed_version(): """Get currently installed Claude Code version. Returns (version_str, artifact_path). Handles both legacy cli.js layout and SEA binary layout. Priority for SEA: package.json → claude --version Priority for cli.js: bundle scan → claude --version → package.json After patching, the cli.js bundle contains the real version while package.json may still reflect the older npm-installed version. For SEA we trust package.json because the binary is opaque. """ artifact = find_claude_artifact() if not artifact: return None, None bn = os.path.basename(artifact) is_sea = bn in ("claude.exe", "claude") and not artifact.endswith(".js") if is_sea: # 1. package.json sits two levels up from bin/claude.exe pkg_json = os.path.join(os.path.dirname(os.path.dirname(artifact)), "package.json") if os.path.isfile(pkg_json): try: with open(pkg_json) as f: data = json.load(f) v = data.get("version") if v: return v, artifact except Exception: pass # 2. Fall back to claude --version try: result = run_cmd( ["claude", "--version"], capture_output=True, text=True, timeout=10, ) m = re.search(r"(\d+\.\d+\.\d+)", result.stdout) if m: return m.group(1), artifact except Exception: pass return None, artifact # --- Legacy cli.js path --- # 1. Extract version from cli.js bundle itself (most accurate after patching) try: with open(artifact, "r", encoding="utf-8", errors="ignore") as f: head = f.read(100_000) m = re.search(r'//\s*Version:\s*(\d+\.\d+\.\d+)', head) if not m: m = re.search(r'(?:VERSION|version)\s*[:=]\s*["\'](\d+\.\d+\.\d+)["\']', head) if m: return m.group(1), artifact except Exception: pass # 2. claude --version try: result = run_cmd( ["claude", "--version"], capture_output=True, text=True, timeout=10, ) m = re.search(r"(\d+\.\d+\.\d+)", result.stdout) if m: return m.group(1), artifact except Exception: pass # 3. Fallback: package.json (may be stale after cli.js replacement) pkg_json = os.path.join(os.path.dirname(artifact), "package.json") if os.path.isfile(pkg_json): try: with open(pkg_json) as f: data = json.load(f) return data.get("version"), artifact except Exception: pass return None, artifact def get_latest_version(): """Read latest version from local index.json.""" index_path = os.path.join(SCRIPT_DIR, "releases", "index.json") if not os.path.isfile(index_path): return None try: with open(index_path, "r") as f: data = json.load(f) return data.get("latest") except Exception: return None def ver_tuple(v): """Parse version string to tuple for comparison.""" m = re.match(r"(\d+)\.(\d+)\.(\d+)", v or "") return (int(m.group(1)), int(m.group(2)), int(m.group(3))) if m else (0, 0, 0) # Markers left by the patcher — if any is missing, the artifact is not patched. # Legacy cli.js markers (text patches in JS bundle): PATCH_MARKERS_JS = [ "__CLAUDE_SETTINGS__", "/*bypass_permissions_prompt*/", "/* root check removed by patcher */", ] # SEA binary markers (byte-level patches inside SEA payload): PATCH_MARKERS_SEA = [ b"/*bypass_permissions_prompt", b"/*ae1_models_filter_patched", ] def is_patched(artifact_path): """Check if installed artifact (cli.js OR claude.exe) is patched. Returns (patched: bool, missing: list[str|bytes]). Auto-detects layout from file basename. """ if not artifact_path or not os.path.isfile(artifact_path): return False, PATCH_MARKERS_JS[:] bn = os.path.basename(artifact_path) is_sea = bn in ("claude.exe", "claude") and not artifact_path.endswith(".js") if is_sea: # SEA: scan as binary; markers are bytes try: with open(artifact_path, "rb") as f: content = f.read() except Exception: return False, [m.decode() for m in PATCH_MARKERS_SEA] missing = [m for m in PATCH_MARKERS_SEA if m not in content] return len(missing) == 0, [m.decode() for m in missing] # Legacy cli.js text scan try: with open(artifact_path, "r", encoding="utf-8", errors="ignore") as f: content = f.read() except Exception: return False, PATCH_MARKERS_JS[:] missing = [m for m in PATCH_MARKERS_JS if m not in content] return len(missing) == 0, missing # ============================================================ # Git pull # ============================================================ def git_pull(): """Pull latest changes from remote (shallow fetch for minimal download).""" try: # Shallow fetch + reset — downloads only latest commit, not full history result = run_cmd( ["git", "fetch", "--depth", "1", "origin", "master"], cwd=REPO_ROOT, capture_output=True, text=True, timeout=60, ) if result.returncode != 0: # Fallback to regular pull result = run_cmd( ["git", "pull", "--quiet"], cwd=REPO_ROOT, capture_output=True, text=True, timeout=60, ) if result.returncode != 0: eprint(f" {Y}git pull warning: {result.stderr.strip()}{D}") return True # Reset to fetched state run_cmd( ["git", "reset", "--hard", "origin/master"], cwd=REPO_ROOT, capture_output=True, text=True, timeout=10, ) # Setup sparse checkout to download only latest version's cli.js _setup_sparse_checkout() return True except subprocess.TimeoutExpired: eprint(f" {Y}git fetch timed out{D}") return True except FileNotFoundError: eprint(f" {Y}git not found, skipping pull{D}") return True def _setup_sparse_checkout(): """Configure sparse checkout to only include root files + latest release. This avoids downloading cli.js for ALL versions (each ~12MB). Only the latest version's cli.js is checked out. """ index_path = os.path.join(SCRIPT_DIR, "releases", "index.json") if not os.path.isfile(index_path): return try: with open(index_path, "r") as f: latest = json.load(f).get("latest") except Exception: return if not latest: return # Enable sparse checkout run_cmd( ["git", "config", "core.sparseCheckout", "true"], cwd=REPO_ROOT, capture_output=True, ) sparse_file = os.path.join(REPO_ROOT, ".git", "info", "sparse-checkout") os.makedirs(os.path.dirname(sparse_file), exist_ok=True) patterns = [ "/*", # root files (updater, config, README) "/claude/releases/index.json", # version index f"/claude/releases/v{latest}/", # latest release (cli.js + changelog + install) ] with open(sparse_file, "w") as f: f.write("\n".join(patterns) + "\n") # Apply sparse checkout run_cmd( ["git", "checkout", "HEAD", "--", "."], cwd=REPO_ROOT, capture_output=True, timeout=30, ) # ============================================================ # CLI.js installation # ============================================================ def install_cli_js(version, cli_js_path): """Install patched cli.js for the given version.""" release_cli = os.path.join(SCRIPT_DIR, "releases", f"v{version}", "cli.js") if not os.path.isfile(release_cli): eprint(f" {R}Release cli.js not found: {release_cli}{D}") return False # Backup timestamp = time.strftime("%Y%m%d%H%M%S") backup_path = f"{cli_js_path}.bak.{timestamp}" try: shutil.copy2(cli_js_path, backup_path) shutil.copy2(release_cli, cli_js_path) # Ensure execute permission (755) os.chmod(cli_js_path, 0o755) # Syntax check result = run_cmd( ["node", "--check", cli_js_path], capture_output=True, text=True, timeout=30, ) if result.returncode != 0: eprint(f" {R}Syntax check FAILED, rolling back...{D}") shutil.copy2(backup_path, cli_js_path) return False print(f" {G}cli.js installed successfully{D}") print(f" Backup: {backup_path}") return True except Exception as e: eprint(f" {R}Installation error: {e}{D}") if os.path.isfile(backup_path): shutil.copy2(backup_path, cli_js_path) eprint(f" Rolled back to backup") return False # ============================================================ # Settings patching # ============================================================ CONFIG_URL = "https://git.sensey24.ru/aibot777/unlimitedcoding-config/raw/branch/main/patcher.config.json" CONFIG_TOKEN = "cadffcb0a6a3be728ac1ff619bb40c86588f6837" PLACEHOLDER_API_KEYS = {"YOUR_API_KEY", "PLACEHOLDER", "REDACTED", "", None} def _config_is_usable(data): """Reject configs with placeholder api_key — public sanitized files have api_key='YOUR_API_KEY' and would silently install with broken auth + stale models list.""" if not isinstance(data, dict): return False return data.get("api_key") not in PLACEHOLDER_API_KEYS def load_config(): """Load patcher.config.json from private config repo (with token auth). Falls back to LOCAL CACHE if network is unavailable. Refuses to use public sanitized patcher.config.json (api_key='YOUR_API_KEY') — that would silently install broken auth, masking the real failure. """ # 1. Try fetching from private repo (with retry on transient gitea 502) last_err = None for attempt in range(1, 4): try: import urllib.request req = urllib.request.Request( CONFIG_URL, headers={"Authorization": f"token {CONFIG_TOKEN}"}, ) with urllib.request.urlopen(req, timeout=15) as resp: data = json.loads(resp.read().decode("utf-8")) if _config_is_usable(data): # Cache locally for offline use cache_path = os.path.join(SCRIPT_DIR, ".patcher.config.cache.json") try: with open(cache_path, "w") as f: json.dump(data, f, indent=2) except Exception: pass return data eprint(f" {Y}Remote config returned placeholder api_key (attempt {attempt}/3){D}") last_err = "placeholder api_key" except Exception as e: last_err = e eprint(f" {Y}Remote config fetch failed (attempt {attempt}/3): {e}{D}") if attempt < 3: time.sleep(2 * attempt) # 2. Fallback: cached copy from previous successful fetch cache_path = os.path.join(SCRIPT_DIR, ".patcher.config.cache.json") if os.path.isfile(cache_path): try: with open(cache_path, "r") as f: cached = json.load(f) if _config_is_usable(cached): eprint(f" {Y}Using cached config (remote fetch failed){D}") return cached except Exception: pass # 3. NO MORE local-file fallback — public patcher.config.json is sanitized # with placeholder api_key. Using it would silently install broken auth. eprint(f" {R}Cannot load config: remote unreachable AND no usable cache.{D}") eprint(f" {R} Last error: {last_err}{D}") eprint(f" {R} Try again later or set UCLAUDE_API_KEY env var manually.{D}") return None def ensure_dir(path, uid, gid): os.makedirs(path, mode=0o700, exist_ok=True) safe_chmod(path, 0o700) safe_chown(path, uid, gid) def read_settings(path): if not os.path.exists(path): return {} try: with open(path, "r", encoding="utf-8") as handle: data = json.load(handle) return data if isinstance(data, dict) else {} except Exception: timestamp = time.strftime("%Y%m%d%H%M%S") backup_path = f"{path}.bak.{timestamp}" os.rename(path, backup_path) print(f" Backed up invalid settings to {backup_path}") return {} def write_settings(path, data, uid, gid): tmp_path = f"{path}.tmp" with open(tmp_path, "w", encoding="utf-8") as handle: json.dump(data, handle, indent=2, ensure_ascii=True) handle.write("\n") os.replace(tmp_path, path) safe_chmod(path, 0o600) safe_chown(path, uid, gid) def patch_user(user_home, user_name, uid, gid, config): """Patch settings for a single user.""" settings_dir = os.path.join(user_home, ".claude") settings_path = os.path.join(settings_dir, "settings.json") ensure_dir(settings_dir, uid, gid) data = read_settings(settings_path) env = data.get("env") if not isinstance(env, dict): env = {} env["ANTHROPIC_AUTH_TOKEN"] = config["api_key"] env["ANTHROPIC_BASE_URL"] = config["base_url"] env.pop("ANTHROPIC_MODEL", None) if "timeout_ms" in config and config["timeout_ms"] is not None: env["API_TIMEOUT_MS"] = str(config["timeout_ms"]) # NOTE: CLAUDE_CUSTOM_MODELS env var was removed upstream in v2.1.114+. # The /model picker now reads from settings.json `availableModels` array # (see Zod schema in cli.js / SEA binary). We still set the env var for # backward-compat with any older binary that may be hanging around. if config.get("models"): env["CLAUDE_CUSTOM_MODELS"] = ",".join(config["models"]) if config.get("default_sonnet_model"): env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = config["default_sonnet_model"] if config.get("default_opus_model"): env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = config["default_opus_model"] env["DISABLE_AUTOUPDATER"] = "1" env["DISABLE_TELEMETRY"] = "1" env["DISABLE_ERROR_REPORTING"] = "1" env["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1" env["CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY"] = "1" env["CLAUDE_CODE_EFFORT_LEVEL"] = config.get("effort_level", "high") env["IS_SANDBOX"] = "1" env["CLAUDE_CODE_BUBBLEWRAP"] = "1" data["env"] = env data["model"] = config["model"] data.pop("models", None) # SEA v2.1.114+: settings.json `availableModels` is the allowlist that # populates the /model picker. Empty array = only default visible. # Undefined = built-ins only. We write the full models list so the picker # shows everything from patcher.config.json. if config.get("models"): data["availableModels"] = list(config["models"]) else: data.pop("availableModels", None) data["effortLevel"] = config.get("effort_level", "high") theme = config.get("theme") if theme: data["theme"] = theme if config.get("complete_onboarding"): data["hasCompletedOnboarding"] = True version = config.get("target_version") or "2.1.50" data["lastOnboardingVersion"] = { "ISSUES_EXPLAINER": "report the issue at https://github.com/anthropics/claude-code/issues", "PACKAGE_URL": "@anthropic-ai/claude-code", "README_URL": "https://code.claude.com/docs/en/overview", "VERSION": version, } perms = data.get("permissions") if not isinstance(perms, dict): perms = {} perms["defaultMode"] = "bypassPermissions" data["permissions"] = perms write_settings(settings_path, data, uid, gid) # --- settings.local.json --- local_path = os.path.join(settings_dir, "settings.local.json") local_data = read_settings(local_path) local_perms = local_data.get("permissions") if not isinstance(local_perms, dict): local_perms = {} local_perms["defaultMode"] = "bypassPermissions" base_allow = [ "Bash", "Edit", "Write", "Read", "Glob", "Grep", "NotebookEdit", "WebFetch", "WebSearch", "mcp__memory-bank__list_projects", "mcp__memory-bank__list_project_files", "mcp__memory-bank__memory_bank_read", "mcp__memory-bank__memory_bank_write", "mcp__memory-bank__memory_bank_update", ] bash_cmds = [ "git", "python3", "python", "node", "npm", "npx", "bash", "sh", "ls", "cat", "wc", "ln", "cp", "mv", "rm", "mkdir", "chmod", "chown", "tail", "head", "touch", "tee", "echo", "printf", "date", "sleep", "sort", "uniq", "tr", "cut", "xargs", "find", "grep", "sed", "awk", "jq", "diff", "curl", "wget", "tar", "gzip", "gunzip", "unzip", "sha256sum", "md5sum", "du", "df", "free", "ps", "kill", "whoami", "hostname", "uname", "go", "make", "systemctl", "journalctl", "docker", "docker-compose", "ssh", "scp", "rsync", "pip", "pip3", "gh", "claude", "entire", "cd", ] for cmd in bash_cmds: base_allow.append(f"Bash({cmd}:*)") existing_allow = local_perms.get("allow", []) existing_set = set(existing_allow) for item in base_allow: if item not in existing_set: existing_allow.append(item) local_perms["allow"] = existing_allow if "deny" not in local_perms: local_perms["deny"] = [] if "ask" not in local_perms: local_perms["ask"] = [] local_data["permissions"] = local_perms write_settings(local_path, local_data, uid, gid) return settings_path def discover_users(): """Find all users with home directories.""" if IS_WINDOWS or not HAS_PWD: home = os.path.expanduser("~") try: username = os.getlogin() except Exception: username = os.environ.get("USER", "user") # Return as simple namespace class User: def __init__(self, name, home, uid, gid): self.name = name self.home = home self.uid = uid self.gid = gid return [User(username, home, os.getuid() if not IS_WINDOWS else 0, os.getgid() if not IS_WINDOWS else 0)] users = [] for entry in pwd.getpwall(): if entry.pw_uid < 500 and entry.pw_name != "root": continue if entry.pw_shell in ("/usr/sbin/nologin", "/bin/false", "/sbin/nologin"): continue if not os.path.isdir(entry.pw_dir): continue class User: def __init__(self, name, home, uid, gid): self.name = name self.home = home self.uid = uid self.gid = gid users.append(User(entry.pw_name, entry.pw_dir, entry.pw_uid, entry.pw_gid)) return users def patch_all_users(config): """Patch settings for all discovered users.""" users = discover_users() if not users: eprint(f" {Y}No users found{D}") return # Diagnostic: confirm config has the key fields BEFORE patching users. # If models is empty/missing, claude shows only built-in defaults # (user reported only 5 models in /model picker — root cause was here). n_models = len(config.get("models", [])) if n_models == 0: eprint(f" {R}WARNING: config has 0 models — claude /model will show built-ins only{D}") else: print(f" Config has {n_models} models (sample: {','.join(config['models'][:3])}...)") if not config.get("api_key") or config["api_key"] in ("YOUR_API_KEY", "PLACEHOLDER", ""): eprint(f" {R}WARNING: api_key is placeholder/empty — claude API auth will fail{D}") for user in users: try: path = patch_user(user.home, user.name, user.uid, user.gid, config) # Verify what landed in settings.json try: with open(path, "r") as f: written = json.load(f) env_block = written.get("env", {}) ccm = env_block.get("CLAUDE_CUSTOM_MODELS", "") ccm_count = len(ccm.split(",")) if ccm else 0 am = written.get("availableModels") or [] am_count = len(am) if isinstance(am, list) else 0 print(f" {G}Patched {user.name}{D}: {path}") print(f" availableModels: {am_count} models " f"(env.CLAUDE_CUSTOM_MODELS legacy: {ccm_count})") except Exception: print(f" {G}Patched {user.name}{D}: {path}") except Exception as e: eprint(f" {R}Failed to patch {user.name}: {e}{D}") # Windows extras if IS_WINDOWS: _set_user_env_windows(config) def _set_user_env_windows(config): """Set user-level environment variables via setx (Windows only).""" env_vars = { "ANTHROPIC_BASE_URL": config["base_url"], "ANTHROPIC_AUTH_TOKEN": config["api_key"], "DISABLE_TELEMETRY": "1", "DISABLE_ERROR_REPORTING": "1", "DISABLE_AUTOUPDATER": "1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1", "CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY": "1", } if config.get("models"): env_vars["CLAUDE_CUSTOM_MODELS"] = ",".join(config["models"]) if config.get("timeout_ms"): env_vars["API_TIMEOUT_MS"] = str(config["timeout_ms"]) for key, val in env_vars.items(): try: run_cmd(["setx", key, val], capture_output=True, check=True) os.environ[key] = val except Exception: pass # ============================================================ # SEA install support (Claude Code 2.1.114+) # ============================================================ import hashlib as _hashlib # local alias to avoid shadowing def _file_sha256(path): h = _hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(1 << 16), b""): h.update(chunk) return h.hexdigest() def detect_release_type(repo_root, version): """Determine layout of releases/v/. Returns: "sea" — releases/v/sea/{claude, cli-wrapper.cjs} present "cli_js" — releases/v/cli.js present (legacy) None — neither found """ rel = os.path.join(repo_root, "releases", f"v{version}") if os.path.isfile(os.path.join(rel, "sea", "claude")) and \ os.path.isfile(os.path.join(rel, "sea", "cli-wrapper.cjs")): return "sea" if os.path.isfile(os.path.join(rel, "cli.js")): return "cli_js" return None def _atomic_copy_with_fsync(src, dst, mode=None): """Copy src → dst.new, fsync file + parent dir, then atomic rename to dst. Crash-safe: if killed mid-copy, dst still points to old content. fsync on both file and parent directory ensures bytes AND the rename survive crash/power-loss (per POSIX: rename only durable after parent fsync). """ tmp = dst + ".new" try: with open(src, "rb") as fsrc, open(tmp, "wb") as fdst: shutil.copyfileobj(fsrc, fdst, length=1 << 20) fdst.flush() os.fsync(fdst.fileno()) if mode is not None: os.chmod(tmp, mode) # Preserve mtime/atime from source (mirrors copy2 behavior) src_stat = os.stat(src) os.utime(tmp, (src_stat.st_atime, src_stat.st_mtime)) os.replace(tmp, dst) # fsync the parent dir so the rename itself is durable # (per dual-critic FIX round 2 — without this, a power-loss after # rename can leave dst pointing at the old inode) parent = os.path.dirname(dst) or "." try: dir_fd = os.open(parent, os.O_RDONLY) try: os.fsync(dir_fd) finally: os.close(dir_fd) except OSError: pass # best-effort; some filesystems don't support dir fsync except Exception: # Cleanup orphan .new on any failure (per dual-critic FIX round 2) try: if os.path.exists(tmp): os.unlink(tmp) except OSError: pass raise def install_sea_release(repo_root, version, install_root, bin_symlink_target=None): """Install patched SEA artifacts (binary + wrapper) into install_root. install_root layout (after install): /node_modules/@anthropic-ai/claude-code/ ├─ bin/claude.exe (patched native binary, mode 0755) ├─ cli-wrapper.cjs (patched wrapper with ENV overrides) └─ package.json (preserved if already present) Production-hardened (per dual-critic FIX): 1. Verify sea/manifest.json exists; load expected sha256 hashes 2. **Pre-verify** sha256 of source files BEFORE touching install_root 3. Acquire fcntl.flock on install_root/.uclaude-update.lock to block concurrent runs (cron + manual race) 4. Backup existing → bin/claude.exe.bak. 5. **Atomic copy** via .new + fsync + os.replace (crash-safe) 6. Verify installed sha256; **rollback from backup** on mismatch 7. Update bin_symlink_target (atomic via tmp_link + os.replace) Returns True on success, False if release missing or any verify failure. """ sea = os.path.join(repo_root, "releases", f"v{version}", "sea") binary_src = os.path.join(sea, "claude") wrapper_src = os.path.join(sea, "cli-wrapper.cjs") manifest_src = os.path.join(sea, "manifest.json") if not (os.path.isfile(binary_src) and os.path.isfile(wrapper_src)): eprint(f" {R}SEA release not found at {sea}{D}") return False try: with open(manifest_src) as f: manifest = json.load(f) except (OSError, ValueError) as e: eprint(f" {R}Cannot read manifest.json: {e}{D}") return False # PRE-verify source sha256 — fail fast without touching install_root expected_bin_sha = manifest.get("binary_sha256") expected_wrap_sha = manifest.get("wrapper_sha256") if expected_bin_sha and _file_sha256(binary_src) != expected_bin_sha: eprint(f" {R}sha256 mismatch on SOURCE binary {binary_src}{D}") return False if expected_wrap_sha and _file_sha256(wrapper_src) != expected_wrap_sha: eprint(f" {R}sha256 mismatch on SOURCE wrapper{D}") return False pkg = os.path.join(install_root, "node_modules", "@anthropic-ai", "claude-code") bin_dir = os.path.join(pkg, "bin") binary_dst = os.path.join(bin_dir, "claude.exe") wrapper_dst = os.path.join(pkg, "cli-wrapper.cjs") os.makedirs(bin_dir, exist_ok=True) # Acquire advisory lock (concurrent run protection) lock_path = os.path.join(install_root, ".uclaude-update.lock") lock_fd = None try: import fcntl as _fcntl lock_fd = open(lock_path, "w") try: _fcntl.flock(lock_fd.fileno(), _fcntl.LOCK_EX | _fcntl.LOCK_NB) except (BlockingIOError, OSError): print(f" {Y}Another uclaude_updater run holds the lock; waiting...{D}") _fcntl.flock(lock_fd.fileno(), _fcntl.LOCK_EX) except ImportError: pass # Non-POSIX (Windows) — skip lock timestamp = time.strftime("%Y%m%d%H%M%S") binary_backup = None wrapper_backup = None try: # Backup existing files (capture paths for potential rollback) if os.path.isfile(binary_dst): binary_backup = f"{binary_dst}.bak.{timestamp}" shutil.copy2(binary_dst, binary_backup) if os.path.isfile(wrapper_dst): wrapper_backup = f"{wrapper_dst}.bak.{timestamp}" shutil.copy2(wrapper_dst, wrapper_backup) # Atomic crash-safe copy _atomic_copy_with_fsync(binary_src, binary_dst, mode=0o755) _atomic_copy_with_fsync(wrapper_src, wrapper_dst) # Post-install verify; rollback on mismatch (defense vs. fs corruption) if expected_bin_sha and _file_sha256(binary_dst) != expected_bin_sha: eprint(f" {R}sha256 mismatch on installed binary — rolling back{D}") if binary_backup and os.path.isfile(binary_backup): shutil.move(binary_backup, binary_dst) return False if expected_wrap_sha and _file_sha256(wrapper_dst) != expected_wrap_sha: eprint(f" {R}sha256 mismatch on installed wrapper — rolling back{D}") if wrapper_backup and os.path.isfile(wrapper_backup): shutil.move(wrapper_backup, wrapper_dst) return False # Update symlink (atomic via tmp + rename) if bin_symlink_target: tmp_link = bin_symlink_target + ".new" try: if os.path.lexists(tmp_link): os.unlink(tmp_link) os.symlink(binary_dst, tmp_link) os.replace(tmp_link, bin_symlink_target) except OSError as e: eprint(f" {Y}Could not update symlink {bin_symlink_target}: {e}{D}") return True finally: if lock_fd is not None: try: import fcntl as _fcntl _fcntl.flock(lock_fd.fileno(), _fcntl.LOCK_UN) except (ImportError, OSError): pass try: lock_fd.close() except OSError: pass # ============================================================ # Main commands # ============================================================ def cmd_check(): """Check for updates without installing.""" installed, cli_js = get_installed_version() latest = get_latest_version() print(f"\n{W}=== UClaude Update Check ==={D}") print(f" Installed: {installed or 'not found'}") print(f" Latest: {latest or 'unknown'}") if not installed: print(f" {Y}Claude Code not found. Run without --check to auto-install.{D}") return 1 if not latest: print(f" {R}Cannot determine latest version. Run 'git pull' first.{D}") return 1 patched, missing = is_patched(cli_js) if patched: print(f" Patched: {G}yes{D}") else: print(f" Patched: {R}NO{D} (missing {len(missing)} markers)") if ver_tuple(latest) > ver_tuple(installed): print(f" {Y}Update available: {installed} → {latest}{D}") return 0 elif not patched: print(f" {Y}Version is current but patches are missing. Run without --check to fix.{D}") return 0 else: print(f" {G}Up to date.{D}") return 0 def cmd_update(force=False, settings_only=False): """Full update: git pull → install cli.js → patch settings.""" print(f"\n{W}=== UClaude Updater ==={D}") # Check Node.js version — HARD STOP if wrong if not ensure_node(): eprint(f"\n {R}Cannot continue without Node.js v{'.'.join(map(str, MIN_NODE_VERSION))}+{D}") eprint(f" Install manually: https://nodejs.org/en/download/") eprint(f" Then re-run: sudo python3 {sys.argv[0]} --force") return 1 # Git pull to get latest artifacts (before npm install so we know target version) print(f"\n Pulling latest updates...") git_pull() latest = get_latest_version() # Ensure Claude Code is installed at the right version if not settings_only: if not ensure_claude_code(target_version=latest): return 1 installed, cli_js = get_installed_version() print(f" Installed: {installed or 'not found'}") print(f" Latest: {latest or 'unknown'}") if not latest: eprint(f" {R}Cannot determine latest version.{D}") return 1 # Check if cli.js is actually patched (markers present) patched, missing_markers = is_patched(cli_js) if patched: print(f" Patch status: {G}patched{D}") elif cli_js: print(f" Patch status: {R}NOT patched{D} (missing {len(missing_markers)} markers)") needs_update = force or not installed or ver_tuple(latest) > ver_tuple(installed) # Even if version matches, re-patch if markers are missing (e.g. npm update overwrote cli.js) if not patched and cli_js and not needs_update: print(f" {Y}Patches missing — cli.js was overwritten. Re-applying...{D}") needs_update = True if not needs_update and not settings_only: print(f"\n {G}Already up to date.{D}") # Still patch settings in case config changed config = load_config() if config: print(f"\n{W}--- Patching settings ---{D}") patch_all_users(config) return 0 # Install cli.js (legacy ≤2.1.113) OR SEA layout (≥2.1.114) — dispatch if not settings_only: if not is_admin(): eprint(f" {R}Root/admin privileges required to update Claude Code.{D}") eprint(f" Run with: sudo python3 {sys.argv[0]}") return 1 release_type = detect_release_type(SCRIPT_DIR, latest) if release_type == "sea": print(f"\n{W}--- Installing SEA release v{latest} ---{D}") # System install root (npm convention) install_root_candidates = [ "/usr/lib/node_modules/@anthropic-ai/claude-code", "/usr/local/lib/node_modules/@anthropic-ai/claude-code", ] install_root = None for cand in install_root_candidates: if os.path.isdir(os.path.dirname(os.path.dirname(cand))): # Detect the parent npm root containing @anthropic-ai npm_root = os.path.dirname(os.path.dirname(cand)) install_root = npm_root break if not install_root: eprint(f" {R}Could not locate npm root (tried /usr/lib, /usr/local/lib){D}") return 1 ok = install_sea_release( repo_root=SCRIPT_DIR, version=latest, install_root=install_root, bin_symlink_target="/usr/bin/claude", ) if not ok: return 1 print(f" {G}SEA install complete: v{latest}{D}") # CRITICAL: shadow legacy cli.js installs that would still win on PATH. # Background: a system that previously had npm-global cli.js (~v2.1.112) # plus a fresh SEA install in /usr/lib will boot the LEGACY artifact # because ~/.npm-global/bin appears earlier in PATH for some users. # We rename any cli.js artifact to .legacy.bak so `claude --version` # honestly reports the new SEA version. shadowed = [] for legacy in find_all_cli_js(): if legacy.endswith(".js"): backup = legacy + ".legacy.bak" try: # Don't shadow our own freshly-installed package # (e.g. /usr/lib/.../claude-code/cli.js if SEA repackaged # such artifact). Check sha256 manifest match before move. os.rename(legacy, backup) shadowed.append(legacy) except OSError as e: eprint(f" {Y}Could not shadow legacy {legacy}: {e}{D}") if shadowed: print(f" {Y}Shadowed {len(shadowed)} legacy cli.js install(s) " f"→ .legacy.bak (PATH will now resolve to SEA){D}") # Hard verify: spawn fresh `claude --version` and assert it matches # the version we just installed. This catches: PATH cache, stale # symlinks, ~/.npm-global vs /usr/lib mismatches, anything weird. try: # Use absolute path to avoid PATH cache surprises vresult = subprocess.run( ["/usr/bin/claude", "--version"], capture_output=True, text=True, timeout=15, ) m = re.search(r"(\d+\.\d+\.\d+)", vresult.stdout) actual_ver = m.group(1) if m else None if actual_ver == latest: print(f" {G}Verified: /usr/bin/claude --version = {actual_ver}{D}") else: eprint(f" {R}WARN: /usr/bin/claude --version = " f"{actual_ver or '?'} but expected {latest}.{D}") eprint(f" {Y}Check `which claude` — another install may " f"shadow /usr/bin/claude on PATH.{D}") except Exception as e: eprint(f" {Y}Could not verify /usr/bin/claude --version: {e}{D}") elif release_type == "cli_js": all_paths = find_all_cli_js() if not all_paths: cli_js_single = find_cli_js() if cli_js_single: all_paths = [cli_js_single] if not all_paths: eprint(f" {R}Claude Code cli.js not found even after install attempt.{D}") return 1 print(f"\n{W}--- Installing cli.js v{latest} (found {len(all_paths)} location(s)) ---{D}") any_ok = False for path in all_paths: print(f" Patching: {path}") ok = install_cli_js(latest, path) if ok: any_ok = True else: eprint(f" {Y}Failed to patch {path}, continuing...{D}") if not any_ok: return 1 else: eprint(f" {R}No release artifacts found for v{latest} " f"(neither sea/ nor cli.js). Run git pull?{D}") return 1 # Patch settings config = load_config() if config: print(f"\n{W}--- Patching settings ---{D}") patch_all_users(config) else: eprint(f" {Y}No config found, skipping settings patch{D}") # Verify new_ver, _ = get_installed_version() print(f"\n{W}=== Done ==={D}") print(f" Version: {new_ver or 'unknown'}") print(f" {G}Update complete.{D}") return 0 def main(): parser = argparse.ArgumentParser( description="UClaude Updater — automatic Claude Code patch updater.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__, ) parser.add_argument("--check", action="store_true", help="Only check for updates") parser.add_argument("--force", action="store_true", help="Force update even if version matches") parser.add_argument("--settings-only", action="store_true", help="Only patch settings, don't touch cli.js") args = parser.parse_args() if args.check: return cmd_check() else: return cmd_update(force=args.force, settings_only=args.settings_only) if __name__ == "__main__": raise SystemExit(main())