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]")
|
console.print(" Next: [cyan]xiaohe[/cyan]")
|
||||||
else:
|
else:
|
||||||
console.print(
|
console.print(
|
||||||
" Launch: [cyan]myclaude[/cyan] / [cyan]mycodex[/cyan] / …"
|
" Launch: [cyan]myclaude[/cyan] / [cyan]mydsh[/cyan] / "
|
||||||
|
"[cyan]mycodex[/cyan] / …"
|
||||||
)
|
)
|
||||||
return still
|
return still
|
||||||
|
|
||||||
|
|||||||
+209
-21
@@ -3,6 +3,7 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import platform
|
||||||
import re
|
import re
|
||||||
import select
|
import select
|
||||||
import shlex
|
import shlex
|
||||||
@@ -37,6 +38,17 @@ _BACKENDS: dict[str, dict] = {
|
|||||||
"session_pattern": "*.jsonl",
|
"session_pattern": "*.jsonl",
|
||||||
"default_args": ["--dangerously-skip-permissions"],
|
"default_args": ["--dangerously-skip-permissions"],
|
||||||
"install_cmd": ["npm", "install", "-g", "@anthropic-ai/claude-code"],
|
"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": (
|
"not_found_msg": (
|
||||||
"[red]claude CLI not found in PATH.[/red] Install Claude Code or set "
|
"[red]claude CLI not found in PATH.[/red] Install Claude Code or set "
|
||||||
"[cyan]CLAUDE_BIN[/cyan]."
|
"[cyan]CLAUDE_BIN[/cyan]."
|
||||||
@@ -60,7 +72,23 @@ _BACKENDS: dict[str, dict] = {
|
|||||||
"session_pattern": "**/*.jsonl",
|
"session_pattern": "**/*.jsonl",
|
||||||
"default_args": ["--dangerously-bypass-approvals-and-sandbox"],
|
"default_args": ["--dangerously-bypass-approvals-and-sandbox"],
|
||||||
"install_cmd": ["npm", "install", "-g", "@openai/codex"],
|
"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 "
|
"[red]codex CLI not found in PATH.[/red] Install with "
|
||||||
"[cyan]npm install -g @openai/codex[/cyan] or set [cyan]CODEX_BIN[/cyan]."
|
"[cyan]npm install -g @openai/codex[/cyan] or set [cyan]CODEX_BIN[/cyan]."
|
||||||
),
|
),
|
||||||
@@ -92,8 +120,7 @@ _BACKENDS: dict[str, dict] = {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
"dsh": {
|
"dsh": {
|
||||||
# DeepSeek Harness is a dev-preview profile launcher; pass flags through
|
# DeepSeek Harness: map myclaude-style ``-r`` to ``--resume``.
|
||||||
# unchanged and let the CLI own resume syntax (--profile / --resume).
|
|
||||||
"binary": "dsh",
|
"binary": "dsh",
|
||||||
"env_bin": "DSH_BIN",
|
"env_bin": "DSH_BIN",
|
||||||
"sessions_root": lambda: Path(
|
"sessions_root": lambda: Path(
|
||||||
@@ -103,6 +130,7 @@ _BACKENDS: dict[str, dict] = {
|
|||||||
"session_pattern": "*/*/session.jsonl.zstd",
|
"session_pattern": "*/*/session.jsonl.zstd",
|
||||||
"default_args": [],
|
"default_args": [],
|
||||||
"install_cmd": ["npm", "install", "-g", "@deepseek-ai/dsh"],
|
"install_cmd": ["npm", "install", "-g", "@deepseek-ai/dsh"],
|
||||||
|
"npm_package": "@deepseek-ai/dsh",
|
||||||
"not_found_msg": (
|
"not_found_msg": (
|
||||||
"[red]dsh CLI not found in PATH.[/red] Install DeepSeek Harness "
|
"[red]dsh CLI not found in PATH.[/red] Install DeepSeek Harness "
|
||||||
"with [cyan]npm install -g @deepseek-ai/dsh[/cyan] or set "
|
"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:
|
def _resolve_chat_cwd(cwd: str | None) -> Path:
|
||||||
"""Working directory: --cwd if given, else workspace/."""
|
"""Working directory: --cwd if given, else workspace/."""
|
||||||
@@ -187,16 +222,43 @@ def _translate_cursor_extra(extra: list[str]) -> list[str]:
|
|||||||
return extra
|
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:
|
def _resolve_backend_binary(config: dict) -> str | None:
|
||||||
"""Resolve backend CLI: env override → PATH → npm global prefix."""
|
"""Resolve backend CLI: env override → PATH → npm global prefix."""
|
||||||
binary = os.environ.get(config["env_bin"]) or shutil.which(config["binary"])
|
env = (os.environ.get(config["env_bin"]) or "").strip()
|
||||||
if binary:
|
if env:
|
||||||
return binary
|
return env
|
||||||
for alt in config.get("alt_binaries", ()):
|
binary = shutil.which(config["binary"])
|
||||||
found = shutil.which(alt)
|
if not binary:
|
||||||
if found:
|
for alt in config.get("alt_binaries", ()):
|
||||||
return found
|
found = shutil.which(alt)
|
||||||
return _resolve_from_npm_global(config)
|
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:
|
def _npm_global_bin() -> Path | None:
|
||||||
@@ -224,6 +286,114 @@ def _npm_global_bin() -> Path | None:
|
|||||||
return root / "bin"
|
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:
|
def _resolve_from_npm_global(config: dict) -> str | None:
|
||||||
"""Look up binary under ``npm prefix -g`` (even when that dir is not on PATH)."""
|
"""Look up binary under ``npm prefix -g`` (even when that dir is not on PATH)."""
|
||||||
names = (config["binary"], *config.get("alt_binaries", ()))
|
names = (config["binary"], *config.get("alt_binaries", ()))
|
||||||
@@ -337,7 +507,7 @@ def _offer_persist_npm_bin(npm_bin: Path) -> None:
|
|||||||
stderr_console.print(
|
stderr_console.print(
|
||||||
f"[yellow]{npm_bin}[/yellow] is not on your login PATH.\n"
|
f"[yellow]{npm_bin}[/yellow] is not on your login PATH.\n"
|
||||||
f" Add it to [cyan]{rc}[/cyan] so new terminals find "
|
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:
|
try:
|
||||||
answer = click.prompt(
|
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:
|
def _run_install(config: dict) -> str | None:
|
||||||
"""Run ``install_cmd`` and return the resolved binary path, or None."""
|
"""Run ``install_cmd`` and return the resolved binary path, or None."""
|
||||||
install_cmd = config.get("install_cmd")
|
install_cmd = config.get("install_cmd")
|
||||||
if not install_cmd:
|
if not install_cmd:
|
||||||
return None
|
return None
|
||||||
installer = shutil.which(install_cmd[0])
|
argv = _npm_install_argv(list(install_cmd))
|
||||||
|
installer = shutil.which(argv[0])
|
||||||
if not installer:
|
if not installer:
|
||||||
hint = _ensure_agent_hint()
|
hint = _ensure_agent_hint()
|
||||||
stderr_console.print(
|
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"
|
f"{config['binary']}.\n"
|
||||||
" Run: [cyan]brew install node[/cyan] (or install Node.js), then "
|
" Run: [cyan]brew install node[/cyan] (or install Node.js), then "
|
||||||
f"[cyan]{hint}[/cyan]."
|
f"[cyan]{hint}[/cyan]."
|
||||||
@@ -441,12 +620,12 @@ def _run_install(config: dict) -> str | None:
|
|||||||
return None
|
return None
|
||||||
stderr_console.print(
|
stderr_console.print(
|
||||||
f"[dim]Installing {config['binary']} via[/dim] "
|
f"[dim]Installing {config['binary']} via[/dim] "
|
||||||
f"[cyan]{shlex.join(install_cmd)}[/cyan]"
|
f"[cyan]{shlex.join(argv)}[/cyan]"
|
||||||
)
|
)
|
||||||
stderr_console.print(
|
stderr_console.print(
|
||||||
"[dim] (npm global install — may take a minute)[/dim]"
|
"[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:
|
if proc.returncode != 0:
|
||||||
hint = _ensure_agent_hint()
|
hint = _ensure_agent_hint()
|
||||||
stderr_console.print(
|
stderr_console.print(
|
||||||
@@ -456,7 +635,18 @@ def _run_install(config: dict) -> str | None:
|
|||||||
return None
|
return None
|
||||||
# Refresh PATH for this process; offer to persist npm bin into shell rc.
|
# Refresh PATH for this process; offer to persist npm bin into shell rc.
|
||||||
ensure_npm_bin_on_path(persist=None)
|
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:
|
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(
|
stderr_console.print(
|
||||||
f"[yellow]{config['binary']} CLI is missing[/yellow] "
|
f"[yellow]{config['binary']} CLI is missing[/yellow] "
|
||||||
"(needed to launch this agent).\n"
|
"(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"
|
default = "y" if shutil.which(install_cmd[0]) else "n"
|
||||||
prompt = (
|
prompt = (
|
||||||
@@ -555,9 +745,7 @@ def _launch(
|
|||||||
elif backend == "cursor":
|
elif backend == "cursor":
|
||||||
extra = _translate_cursor_extra(extra)
|
extra = _translate_cursor_extra(extra)
|
||||||
elif backend == "dsh":
|
elif backend == "dsh":
|
||||||
# DeepSeek Harness takes --profile <name> before the task; a bare
|
extra = _translate_dsh_extra(extra)
|
||||||
# --resume <id> passes through as-is (profile launcher owns it).
|
|
||||||
pass
|
|
||||||
|
|
||||||
cmd = [binary, *config["default_args"], *extra]
|
cmd = [binary, *config["default_args"], *extra]
|
||||||
if use_tmux:
|
if use_tmux:
|
||||||
|
|||||||
Reference in New Issue
Block a user