fix(updater): SEA-aware install detection — recognise bin/claude.exe
After upstream switched to SEA (Single Executable Application) layout in v2.1.114+, there is no cli.js anymore — only bin/claude.exe. Before this fix: - find_cli_js() searched only for cli.js → returned None on SEA installs - get_installed_version() returned (None, None) → "Claude Code: not installed" - is_patched() returned (False, [3 markers]) → false-negative even after successful patch - cmd_check showed "not installed" right after a successful update - cmd_update would loop reinstalling because patch markers seemed missing This contributed to a user-facing incident where /model picker showed only built-in models even though CLAUDE_CUSTOM_MODELS was set in settings.json: the binary update path was triggering uninstall+reinstall cycles instead of being recognised as already-patched. Changes: - New find_claude_artifact() finds either cli.js or bin/claude.exe, including the deeply-nested layout npm uses for SEA wrapper packages - find_all_cli_js() returns both legacy and SEA artifacts - is_patched() auto-detects layout: text scan for cli.js, bytes scan for claude.exe (markers: /*ae1_models_filter_patched*/, /*bypass_permissions_prompt*/) - get_installed_version() reads package.json next to bin/ for SEA - uclaude_install.sh adds binary marker verification at end of install so users immediately see if the SEA payload was patched Tests: new tests/test_sea_detect.py covers all 4 detector functions against fake SEA install layouts (including nested form). All 20 tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -315,74 +315,130 @@ def ensure_claude_code(target_version=None):
|
||||
# Version detection
|
||||
# ============================================================
|
||||
|
||||
def find_cli_js():
|
||||
"""Find installed Claude Code cli.js path."""
|
||||
candidates = []
|
||||
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:
|
||||
<npm_root>/@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:
|
||||
candidates.append(os.path.join(base, "npm", "node_modules", "@anthropic-ai", "claude-code", "cli.js"))
|
||||
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:
|
||||
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 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"))
|
||||
|
||||
# Prepend npm root -g result (most reliable, works across all Node install methods)
|
||||
# 3. npm root -g
|
||||
try:
|
||||
result = subprocess.run(["npm", "root", "-g"], capture_output=True, text=True, timeout=10)
|
||||
if result.returncode == 0:
|
||||
npm_global = result.stdout.strip()
|
||||
candidates.insert(0, os.path.join(npm_global, "@anthropic-ai", "claude-code", "cli.js"))
|
||||
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
|
||||
|
||||
for path in candidates:
|
||||
# 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 cli.js paths (for multi-install patching)."""
|
||||
"""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:
|
||||
candidates.add(os.path.join(base, "npm", "node_modules",
|
||||
"@anthropic-ai", "claude-code", "cli.js"))
|
||||
_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"):
|
||||
candidates.add(os.path.join(prefix, "node_modules",
|
||||
"@anthropic-ai", "claude-code", "cli.js"))
|
||||
_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:
|
||||
candidates.add(os.path.join(r.stdout.strip(),
|
||||
"@anthropic-ai", "claude-code", "cli.js"))
|
||||
_add_pkg(os.path.join(r.stdout.strip(),
|
||||
"@anthropic-ai", "claude-code"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Resolve `which claude` → follow symlinks → find cli.js
|
||||
# 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())
|
||||
if os.path.basename(claude_bin) == "cli.js":
|
||||
# which claude resolves directly to cli.js
|
||||
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
|
||||
# cli.js is at node_modules/@anthropic-ai/claude-code/cli.js
|
||||
nm = os.path.dirname(os.path.dirname(claude_bin)) # node_modules/
|
||||
candidates.add(os.path.join(nm, "@anthropic-ai", "claude-code", "cli.js"))
|
||||
_add_pkg(os.path.join(nm, "@anthropic-ai", "claude-code"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -395,8 +451,8 @@ def find_all_cli_js():
|
||||
versions_dir = os.path.join(nvm_base, "versions", "node")
|
||||
if os.path.isdir(versions_dir):
|
||||
for node_ver in os.listdir(versions_dir):
|
||||
candidates.add(os.path.join(versions_dir, node_ver, "lib",
|
||||
"node_modules", "@anthropic-ai", "claude-code", "cli.js"))
|
||||
_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)]
|
||||
|
||||
@@ -404,25 +460,58 @@ def find_all_cli_js():
|
||||
def get_installed_version():
|
||||
"""Get currently installed Claude Code version.
|
||||
|
||||
Priority: cli.js bundle version > claude --version > package.json.
|
||||
After patching, cli.js contains the real version while package.json
|
||||
may still reflect the older npm-installed 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.
|
||||
"""
|
||||
cli_js = find_cli_js()
|
||||
if not cli_js:
|
||||
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(cli_js, "r", encoding="utf-8", errors="ignore") as f:
|
||||
# Read first 100KB where version string usually lives
|
||||
with open(artifact, "r", encoding="utf-8", errors="ignore") as f:
|
||||
head = f.read(100_000)
|
||||
# Look for "// Version: x.y.z" comment or VERSION:"x.y.z" in the bundle
|
||||
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), cli_js
|
||||
return m.group(1), artifact
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -434,21 +523,21 @@ def get_installed_version():
|
||||
)
|
||||
m = re.search(r"(\d+\.\d+\.\d+)", result.stdout)
|
||||
if m:
|
||||
return m.group(1), cli_js
|
||||
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(cli_js), "package.json")
|
||||
pkg_json = os.path.join(os.path.dirname(artifact), "package.json")
|
||||
if os.path.isfile(pkg_json):
|
||||
try:
|
||||
with open(pkg_json, "r") as f:
|
||||
with open(pkg_json) as f:
|
||||
data = json.load(f)
|
||||
return data.get("version"), cli_js
|
||||
return data.get("version"), artifact
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None, cli_js
|
||||
return None, artifact
|
||||
|
||||
|
||||
def get_latest_version():
|
||||
@@ -470,24 +559,49 @@ def ver_tuple(v):
|
||||
return (int(m.group(1)), int(m.group(2)), int(m.group(3))) if m else (0, 0, 0)
|
||||
|
||||
|
||||
# Markers left by the patcher in cli.js — if any is missing, cli.js is not patched
|
||||
PATCH_MARKERS = [
|
||||
# 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(cli_js_path):
|
||||
"""Check if cli.js has patch markers. Returns (patched: bool, missing: list)."""
|
||||
if not cli_js_path or not os.path.isfile(cli_js_path):
|
||||
return False, PATCH_MARKERS[:]
|
||||
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(cli_js_path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
with open(artifact_path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
content = f.read()
|
||||
except Exception:
|
||||
return False, PATCH_MARKERS[:]
|
||||
missing = [m for m in PATCH_MARKERS if m not in content]
|
||||
return False, PATCH_MARKERS_JS[:]
|
||||
missing = [m for m in PATCH_MARKERS_JS if m not in content]
|
||||
return len(missing) == 0, missing
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user