From bcd780b04577c872384bdcf9f511eadc3fe9e415 Mon Sep 17 00:00:00 2001 From: Zhengshou Lai Date: Thu, 20 Aug 2026 10:40:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(npm):=20=E5=AE=98=E6=96=B9=E6=BA=90?= =?UTF-8?q?=E8=A3=85=20optional=20=E5=B9=B3=E5=8F=B0=E5=8C=85=EF=BC=8C?= =?UTF-8?q?=E6=8B=92=E7=BB=9D=20stub/=E9=94=99=E6=9E=B6=E6=9E=84=E4=BA=8C?= =?UTF-8?q?=E8=BF=9B=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 国内镜像常只同步主包,Windows 上 claude.exe/codex 会变成 1KB stub。 --- myagents/commands/ensure_agent.py | 3 +- myagents/launcher.py | 230 +++++++++++++++++++++++++++--- 2 files changed, 211 insertions(+), 22 deletions(-) diff --git a/myagents/commands/ensure_agent.py b/myagents/commands/ensure_agent.py index 7825fdf..861c285 100644 --- a/myagents/commands/ensure_agent.py +++ b/myagents/commands/ensure_agent.py @@ -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 diff --git a/myagents/launcher.py b/myagents/launcher.py index 4466040..bd2d49c 100644 --- a/myagents/launcher.py +++ b/myagents/launcher.py @@ -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 `` 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 before the task; a bare - # --resume passes through as-is (profile launcher owns it). - pass + extra = _translate_dsh_extra(extra) cmd = [binary, *config["default_args"], *extra] if use_tmux: