fix(npm): 官方源装 optional 平台包,拒绝 stub/错架构二进制
国内镜像常只同步主包,Windows 上 claude.exe/codex 会变成 1KB stub。
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+205
-17
@@ -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,6 +72,22 @@ _BACKENDS: dict[str, dict] = {
|
||||
"session_pattern": "**/*.jsonl",
|
||||
"default_args": ["--dangerously-bypass-approvals-and-sandbox"],
|
||||
"install_cmd": ["npm", "install", "-g", "@openai/codex"],
|
||||
"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
|
||||
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:
|
||||
return found
|
||||
return _resolve_from_npm_global(config)
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user