Files
unlimitedcoding/uclaude_updater.py
delta-cloud-208e 3bc69d4eff feat: sparse checkout + shallow fetch для минимального трафика
- git_pull() использует fetch --depth 1 + reset (не качает историю)
- sparse checkout: скачивается только latest версия cli.js, не все
- Все старые версии остаются в репо, но клиент их не скачивает
- README обновлён с git clone --depth 1

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 11:42:49 +00:00

613 lines
19 KiB
Python

#!/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__))
# 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)
# ============================================================
# Version detection
# ============================================================
def find_cli_js():
"""Find installed Claude Code cli.js path."""
candidates = []
if IS_WINDOWS:
for env_key in ("APPDATA", "LOCALAPPDATA", "PROGRAMFILES"):
base = os.environ.get(env_key, "")
if base:
candidates.append(os.path.join(base, "npm", "node_modules", "@anthropic-ai", "claude-code", "cli.js"))
else:
candidates = [
"/usr/lib/node_modules/@anthropic-ai/claude-code/cli.js",
"/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js",
"/opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js",
]
for path in candidates:
if os.path.isfile(path):
return path
return None
def get_installed_version():
"""Get currently installed Claude Code version."""
cli_js = find_cli_js()
if not cli_js:
return None, None
# Try package.json first (same directory as cli.js)
pkg_json = os.path.join(os.path.dirname(cli_js), "package.json")
if os.path.isfile(pkg_json):
try:
with open(pkg_json, "r") as f:
data = json.load(f)
return data.get("version"), cli_js
except Exception:
pass
# Fallback: claude --version
try:
result = subprocess.run(
["claude", "--version"],
capture_output=True, text=True, timeout=10,
)
m = re.search(r"(\d+\.\d+\.\d+)", result.stdout)
if m:
return m.group(1), cli_js
except Exception:
pass
return None, cli_js
def get_latest_version():
"""Read latest version from local index.json."""
index_path = os.path.join(SCRIPT_DIR, "claude", "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)
# ============================================================
# 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 = subprocess.run(
["git", "fetch", "--depth", "1", "origin", "master"],
cwd=SCRIPT_DIR, capture_output=True, text=True, timeout=60,
)
if result.returncode != 0:
# Fallback to regular pull
result = subprocess.run(
["git", "pull", "--quiet"],
cwd=SCRIPT_DIR, 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
subprocess.run(
["git", "reset", "--hard", "origin/master"],
cwd=SCRIPT_DIR, 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, "claude", "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
subprocess.run(
["git", "config", "core.sparseCheckout", "true"],
cwd=SCRIPT_DIR, capture_output=True,
)
sparse_file = os.path.join(SCRIPT_DIR, ".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
subprocess.run(
["git", "checkout", "HEAD", "--", "."],
cwd=SCRIPT_DIR, 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, "claude", "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)
# Syntax check
result = subprocess.run(
["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
# ============================================================
def load_config():
"""Load patcher.config.json from repo root."""
config_path = os.path.join(SCRIPT_DIR, "patcher.config.json")
if not os.path.isfile(config_path):
eprint(f" {R}patcher.config.json not found{D}")
return None
with open(config_path, "r") as f:
return json.load(f)
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"])
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"] = "medium"
data["env"] = env
data["model"] = config["model"]
data.pop("models", None)
data.pop("availableModels", None)
data["effortLevel"] = "medium"
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
for user in users:
try:
path = patch_user(user.home, user.name, user.uid, user.gid, config)
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:
subprocess.run(["setx", key, val], capture_output=True, check=True)
os.environ[key] = val
except Exception:
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. Install it first: npm install -g @anthropic-ai/claude-code{D}")
return 1
if not latest:
print(f" {R}Cannot determine latest version. Run 'git pull' first.{D}")
return 1
if ver_tuple(latest) > ver_tuple(installed):
print(f" {Y}Update available: {installed}{latest}{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."""
installed, cli_js = get_installed_version()
print(f"\n{W}=== UClaude Updater ==={D}")
# Git pull to get latest artifacts
print(f"\n Pulling latest updates...")
git_pull()
latest = get_latest_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
needs_update = force or not installed or ver_tuple(latest) > ver_tuple(installed)
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
if not settings_only:
if not cli_js:
cli_js = find_cli_js()
if not cli_js:
eprint(f" {R}Claude Code cli.js not found. Install Claude Code first.{D}")
return 1
if not is_admin():
eprint(f" {R}Root/admin privileges required to update cli.js.{D}")
eprint(f" Run with: sudo python3 {sys.argv[0]}")
return 1
print(f"\n{W}--- Installing cli.js v{latest} ---{D}")
ok = install_cli_js(latest, cli_js)
if not ok:
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())