fix(npm): 官方源装 optional 平台包,拒绝 stub/错架构二进制

国内镜像常只同步主包,Windows 上 claude.exe/codex 会变成 1KB stub。
This commit is contained in:
Zhengshou Lai
2026-08-20 10:40:43 +08:00
parent 3d8887bba1
commit bcd780b045
2 changed files with 211 additions and 22 deletions
+2 -1
View File
@@ -158,7 +158,8 @@ def run_ensure_agents(
console.print(" Next: [cyan]xiaohe[/cyan]")
else:
console.print(
" Launch: [cyan]myclaude[/cyan] / [cyan]mycodex[/cyan] / "
" Launch: [cyan]myclaude[/cyan] / [cyan]mydsh[/cyan] / "
"[cyan]mycodex[/cyan] / …"
)
return still
+209 -21
View File
@@ -3,6 +3,7 @@
import hashlib
import json
import os
import platform
import re
import select
import shlex
@@ -37,6 +38,17 @@ _BACKENDS: dict[str, dict] = {
"session_pattern": "*.jsonl",
"default_args": ["--dangerously-skip-permissions"],
"install_cmd": ["npm", "install", "-g", "@anthropic-ai/claude-code"],
"npm_package": "@anthropic-ai/claude-code",
"native_min_bytes": 1_000_000,
"native_bin_rel": ("bin/claude.exe", "bin/claude"),
"native_platform_packages": {
"win32-x64": "@anthropic-ai/claude-code-win32-x64",
"win32-arm64": "@anthropic-ai/claude-code-win32-arm64",
"darwin-arm64": "@anthropic-ai/claude-code-darwin-arm64",
"darwin-x64": "@anthropic-ai/claude-code-darwin-x64",
"linux-x64": "@anthropic-ai/claude-code-linux-x64",
"linux-arm64": "@anthropic-ai/claude-code-linux-arm64",
},
"not_found_msg": (
"[red]claude CLI not found in PATH.[/red] Install Claude Code or set "
"[cyan]CLAUDE_BIN[/cyan]."
@@ -60,7 +72,23 @@ _BACKENDS: dict[str, dict] = {
"session_pattern": "**/*.jsonl",
"default_args": ["--dangerously-bypass-approvals-and-sandbox"],
"install_cmd": ["npm", "install", "-g", "@openai/codex"],
"not_found_msg": (
"npm_package": "@openai/codex",
"native_min_bytes": 1_000_000,
"native_bin_rel": (
"codex.exe",
"bin/codex.exe",
"bin/codex",
"vendor/codex.exe",
),
"native_platform_packages": {
"win32-x64": "@openai/codex-win32-x64",
"win32-arm64": "@openai/codex-win32-arm64",
"darwin-arm64": "@openai/codex-darwin-arm64",
"darwin-x64": "@openai/codex-darwin-x64",
"linux-x64": "@openai/codex-linux-x64",
"linux-arm64": "@openai/codex-linux-arm64",
},
"not_found_msg": (
"[red]codex CLI not found in PATH.[/red] Install with "
"[cyan]npm install -g @openai/codex[/cyan] or set [cyan]CODEX_BIN[/cyan]."
),
@@ -92,8 +120,7 @@ _BACKENDS: dict[str, dict] = {
),
},
"dsh": {
# DeepSeek Harness is a dev-preview profile launcher; pass flags through
# unchanged and let the CLI own resume syntax (--profile / --resume).
# DeepSeek Harness: map myclaude-style ``-r`` to ``--resume``.
"binary": "dsh",
"env_bin": "DSH_BIN",
"sessions_root": lambda: Path(
@@ -103,6 +130,7 @@ _BACKENDS: dict[str, dict] = {
"session_pattern": "*/*/session.jsonl.zstd",
"default_args": [],
"install_cmd": ["npm", "install", "-g", "@deepseek-ai/dsh"],
"npm_package": "@deepseek-ai/dsh",
"not_found_msg": (
"[red]dsh CLI not found in PATH.[/red] Install DeepSeek Harness "
"with [cyan]npm install -g @deepseek-ai/dsh[/cyan] or set "
@@ -111,6 +139,13 @@ _BACKENDS: dict[str, dict] = {
},
}
_NPM_OFFICIAL_REGISTRY = "https://registry.npmjs.org/"
_NPM_INSTALL_FLAGS = (
"--include=optional",
"--foreground-scripts",
f"--registry={_NPM_OFFICIAL_REGISTRY}",
)
def _resolve_chat_cwd(cwd: str | None) -> Path:
"""Working directory: --cwd if given, else workspace/."""
@@ -187,16 +222,43 @@ def _translate_cursor_extra(extra: list[str]) -> list[str]:
return extra
def _translate_dsh_extra(extra: list[str]) -> list[str]:
"""Map myclaude-style ``-r`` onto dsh ``--resume``; leave the rest as-is."""
if not extra:
return extra
head = extra[0]
tail = extra[1:]
if head == "-r":
if not tail or tail[0].startswith("-"):
return ["--resume", *tail]
return ["--resume", tail[0], *tail[1:]]
return extra
def agent_entrypoints() -> dict[str, str]:
"""Map ``myclaude`` / ``mydsh`` / … to backend names."""
return {f"my{name}": name for name in _BACKENDS}
def _resolve_backend_binary(config: dict) -> str | None:
"""Resolve backend CLI: env override → PATH → npm global prefix."""
binary = os.environ.get(config["env_bin"]) or shutil.which(config["binary"])
if binary:
return binary
for alt in config.get("alt_binaries", ()):
found = shutil.which(alt)
if found:
return found
return _resolve_from_npm_global(config)
env = (os.environ.get(config["env_bin"]) or "").strip()
if env:
return env
binary = shutil.which(config["binary"])
if not binary:
for alt in config.get("alt_binaries", ()):
found = shutil.which(alt)
if found:
binary = found
break
if not binary:
binary = _resolve_from_npm_global(config)
if not binary:
return None
if not _native_binary_ok(config):
return None
return binary
def _npm_global_bin() -> Path | None:
@@ -224,6 +286,114 @@ def _npm_global_bin() -> Path | None:
return root / "bin"
def _npm_global_modules() -> Path | None:
"""``node_modules`` root for ``npm install -g``, if present."""
npm_bin = _npm_global_bin()
if not npm_bin:
return None
if sys.platform == "win32":
cand = npm_bin / "node_modules"
else:
cand = npm_bin.parent / "lib" / "node_modules"
if not cand.is_dir():
alt = npm_bin.parent / "node_modules"
cand = alt if alt.is_dir() else cand
return cand if cand.is_dir() else None
def _npm_platform_key() -> str:
machine = platform.machine().lower()
if machine in ("x86_64", "amd64"):
cpu = "x64"
elif machine in ("aarch64", "arm64"):
cpu = "arm64"
else:
cpu = machine
if sys.platform == "win32":
os_name = "win32"
elif sys.platform == "darwin":
os_name = "darwin"
else:
os_name = "linux"
return f"{os_name}-{cpu}"
def _native_magic_ok(path: Path) -> bool:
"""True when ``path`` looks like a native binary for this OS."""
try:
with path.open("rb") as fh:
magic = fh.read(4)
except OSError:
return False
if len(magic) < 2:
return False
if sys.platform == "win32":
return magic[:2] == b"MZ"
if sys.platform == "darwin":
return magic in (
b"\xcf\xfa\xed\xfe",
b"\xfe\xed\xfa\xcf",
b"\xca\xfe\xba\xbe",
b"\xbe\xba\xfe\xca",
b"\xce\xfa\xed\xfe",
b"\xfe\xed\xfa\xce",
)
return magic[:4] == b"\x7fELF"
def _looks_like_native_bin(path: Path, *, min_bytes: int) -> bool:
try:
if path.stat().st_size < min_bytes:
return False
except OSError:
return False
return _native_magic_ok(path)
def _native_candidate_paths(config: dict) -> list[Path]:
rels = tuple(config.get("native_bin_rel") or ())
if not rels:
return []
modules = _npm_global_modules()
if modules is None:
return []
out: list[Path] = []
plat_pkgs = config.get("native_platform_packages") or {}
plat_pkg = plat_pkgs.get(_npm_platform_key())
names = (config.get("binary"), *config.get("alt_binaries", ()))
if plat_pkg:
pkg_dir = modules / plat_pkg
for rel in rels:
out.append(pkg_dir / rel)
for name in names:
if not name:
continue
out.append(pkg_dir / name)
if sys.platform == "win32":
out.append(pkg_dir / f"{name}.exe")
main_pkg = config.get("npm_package")
if main_pkg:
pkg_dir = modules / main_pkg
for rel in rels:
out.append(pkg_dir / rel)
return out
def _native_binary_ok(config: dict) -> bool:
"""False when a known native package is present but is a stub/wrong OS.
Unknown layouts (no candidate files) are treated as OK so PATH-only
installs are not blocked.
"""
min_bytes = int(config.get("native_min_bytes") or 0)
if min_bytes <= 0:
return True
found = [p for p in _native_candidate_paths(config) if p.is_file()]
if not found:
return True
return any(_looks_like_native_bin(p, min_bytes=min_bytes) for p in found)
def _resolve_from_npm_global(config: dict) -> str | None:
"""Look up binary under ``npm prefix -g`` (even when that dir is not on PATH)."""
names = (config["binary"], *config.get("alt_binaries", ()))
@@ -337,7 +507,7 @@ def _offer_persist_npm_bin(npm_bin: Path) -> None:
stderr_console.print(
f"[yellow]{npm_bin}[/yellow] is not on your login PATH.\n"
f" Add it to [cyan]{rc}[/cyan] so new terminals find "
f"[cyan]claude[/cyan] / [cyan]codex[/cyan]?"
f"[cyan]claude[/cyan] / [cyan]dsh[/cyan] / [cyan]codex[/cyan]?"
)
try:
answer = click.prompt(
@@ -424,16 +594,25 @@ def _ensure_agent_hint() -> str:
)
def _npm_install_argv(install_cmd: list[str]) -> list[str]:
"""``npm install -g <pkg>`` plus flags so platform optional deps resolve."""
argv = list(install_cmd)
if argv and argv[0] == "npm":
argv.extend(_NPM_INSTALL_FLAGS)
return argv
def _run_install(config: dict) -> str | None:
"""Run ``install_cmd`` and return the resolved binary path, or None."""
install_cmd = config.get("install_cmd")
if not install_cmd:
return None
installer = shutil.which(install_cmd[0])
argv = _npm_install_argv(list(install_cmd))
installer = shutil.which(argv[0])
if not installer:
hint = _ensure_agent_hint()
stderr_console.print(
f"[red]{install_cmd[0]} not found[/red] — cannot auto-install "
f"[red]{argv[0]} not found[/red] — cannot auto-install "
f"{config['binary']}.\n"
" Run: [cyan]brew install node[/cyan] (or install Node.js), then "
f"[cyan]{hint}[/cyan]."
@@ -441,12 +620,12 @@ def _run_install(config: dict) -> str | None:
return None
stderr_console.print(
f"[dim]Installing {config['binary']} via[/dim] "
f"[cyan]{shlex.join(install_cmd)}[/cyan]"
f"[cyan]{shlex.join(argv)}[/cyan]"
)
stderr_console.print(
"[dim] (npm global install — may take a minute)[/dim]"
)
proc = subprocess.run([installer, *install_cmd[1:]], check=False)
proc = subprocess.run([installer, *argv[1:]], check=False)
if proc.returncode != 0:
hint = _ensure_agent_hint()
stderr_console.print(
@@ -456,7 +635,18 @@ def _run_install(config: dict) -> str | None:
return None
# Refresh PATH for this process; offer to persist npm bin into shell rc.
ensure_npm_bin_on_path(persist=None)
return _resolve_backend_binary(config)
resolved = _resolve_backend_binary(config)
if resolved:
return resolved
pkg = str(config.get("npm_package") or config["binary"])
stderr_console.print(
"[red]Installed CLI is not a valid native binary for this OS[/red] "
"(npm mirrors often ship a 1KB stub or the wrong platform).\n"
f" [cyan]npm uninstall -g {pkg}[/cyan]\n"
f" [cyan]npm install -g {pkg} --registry={_NPM_OFFICIAL_REGISTRY} "
"--include=optional --foreground-scripts[/cyan]"
)
return None
def _offer_install(config: dict, *, yes: bool | None = None) -> str | None:
@@ -478,7 +668,7 @@ def _offer_install(config: dict, *, yes: bool | None = None) -> str | None:
stderr_console.print(
f"[yellow]{config['binary']} CLI is missing[/yellow] "
"(needed to launch this agent).\n"
f" Will run: [cyan]{shlex.join(install_cmd)}[/cyan]"
f" Will run: [cyan]{shlex.join(_npm_install_argv(list(install_cmd)))}[/cyan]"
)
default = "y" if shutil.which(install_cmd[0]) else "n"
prompt = (
@@ -555,9 +745,7 @@ def _launch(
elif backend == "cursor":
extra = _translate_cursor_extra(extra)
elif backend == "dsh":
# DeepSeek Harness takes --profile <name> before the task; a bare
# --resume <id> passes through as-is (profile launcher owns it).
pass
extra = _translate_dsh_extra(extra)
cmd = [binary, *config["default_args"], *extra]
if use_tmux: