Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# LMM 安装脚本

根目录直接维护脚本,不生成代码。没有代理配置、测速、镜像、私有 Node、缓存或回滚框架。
根目录直接维护脚本,不生成代码。没有代理配置、测速、镜像、私有 Node、缓存或回滚框架;官方安装器自己的依赖安装和检查保持原样。

```sh
curl -fsSLo menu.sh https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/main/menu.sh
Expand All @@ -15,19 +15,21 @@ irm https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/main/menu.ps1

| 文件名(`.sh` / `.ps1`) | 安装方式 | 启动 |
|---|---|---|
| `pi` | 官方 npm 命令,加 LMM 插件 | `pi`,然后 `/login` |
| `pi` | `pi.dev/install.sh` / `install.ps1`,保留官方交互和依赖处理 | `pi`,然后 `/login` |
| `dsh` | npm + pnpm,加 LMM 插件;默认 web profile | `dsh web` |
| `codex` | 官方原生安装器,参数直接传给官方 | `codex` |
| `claude-code` | 官方原生安装器,参数直接传给官方 | `claude` |
| `cc-switch` | Linux 发行包/AUR,macOS Homebrew,Windows 官方 MSI | 桌面应用 |
| `clash-verge-rev` | Linux deb/rpm/AUR,macOS Homebrew,Windows WinGet | 桌面应用 |
| `lmm` | 0.1.0 预览版发行包;另支持 `--from-source` / `-FromSource` | 安装结束打印的完整路径 |

Pi/DSH 的宿主和插件保留已适配版本;其余工具交给上游维护版本。只安装软件,不写供应商配置、不登录账号、不启用系统代理或 TUN。
Pi 的版本、Node、安装位置、权限和 Windows Git Bash 交给官方安装器。官方正常安装后,才从它选出的安装路径接着装 LMM 插件;取消或卸载不继续。现有 LMM alpha 只验证过 Pi 0.85.1,其他版本会明确跳过插件,不偷偷降级宿主。DSH 的宿主和插件继续固定已适配版本;其当前官方 README 使用 npx,没有现行的独立安装脚本,不能拿归档记录中的旧脚本替代。

不写供应商配置、不登录账号、不启用系统代理或 TUN。

## 环境

Unix 入口需要 Bash、curl。Pi/DSH 另需 Node 22.19+(22.x)或 24+、npm;Windows 的 Pi 需要 Git Bash。脚本不代装 Node,不修改 npm registry。
Unix 入口需要 Bash、curl。Pi 的依赖提示由官方处理;DSH 另需 Node 22.19+(22.x)或 24+、npm。不修改 npm registry。

Codex/Claude 的 Linux、macOS、Windows 安装都用官方脚本,不要求 Node,也不限定 apt 发行版。先满足上游的系统和运行库要求。Alpine 的 Claude 需要 `bash curl libgcc libstdc++ ripgrep`,运行时使用 `USE_BUILTIN_RIPGREP=0 claude`。NixOS 请使用 Nix 包环境,不保证通用二进制可运行。

Expand All @@ -37,7 +39,7 @@ LMM 预览包仅提供 Linux x64(glibc 2.39+)、macOS arm64、Windows x64,

## Termux

Pi 使用 Termux 的原生 Node/npm:`pkg install nodejs npm git`,然后 `bash pi.sh`。DSH 使用同一 npm 安装方式,Android 原生依赖尚未真机验证。
Pi 先运行 `pkg install nodejs npm git` 准备 Android 原生依赖,再调用同一个官方安装器;没有 `TMPDIR` 时使用 `$PREFIX/tmp`。DSH 使用同一 npm 安装方式,Android 原生依赖尚未真机验证。

Codex/Claude 使用**已有的 PRoot Linux 环境**,不是 Android 原生二进制。先准备 `proot-distro` 和 Ubuntu guest,并在 guest 中安装 Bash、curl、CA 证书,再运行相应脚本。其他 guest 用 `LMM_DISTRO=名称 bash codex.sh`。安装后进入同一 guest 运行 `codex` / `claude`;不再生成转发启动器,不创建或重置 guest,不关闭沙箱。两个桌面工具不支持 Termux,菜单会隐藏它们。

Expand All @@ -49,6 +51,6 @@ Codex/Claude 使用**已有的 PRoot Linux 环境**,不是 Android 原生二

## 依据与测试

2026-09-21 核查:[Codex](https://learn.chatgpt.com/docs/codex/cli) · [Claude Code](https://code.claude.com/docs/en/setup) · [Pi](https://pi.dev/docs/latest/quickstart) · [Pi Termux](https://pi.dev/docs/latest/termux) · [DSH](https://github.com/deepseek-ai/deepseek-harness) · [DSH 插件](https://deepseek-harness.github.io/deepseek-harness/en/develop/basic/publish) · [CC Switch](https://github.com/farion1231/cc-switch#download--installation) · [Clash Verge Rev](https://www.clashverge.dev/install.html)。LMM 插件适配版本见 [Pi 插件](https://github.com/TokenNotIncluded/pi-lmm-provider) 和 [DSH 插件](https://github.com/TokenNotIncluded/dsh-lmm-provider)。
2026-09-21 核查:[Pi 官网安装入口](https://pi.dev/)([Shell](https://pi.dev/install.sh) / [PowerShell](https://pi.dev/install.ps1))· [Codex](https://learn.chatgpt.com/docs/codex/cli) · [Claude Code](https://code.claude.com/docs/en/setup) · [Pi Termux](https://pi.dev/docs/latest/termux) · [DSH 当前 README](https://github.com/deepseek-ai/deepseek-harness/blob/master/README.md) · [DSH 插件](https://deepseek-harness.github.io/deepseek-harness/en/develop/basic/publish) · [CC Switch](https://github.com/farion1231/cc-switch#download--installation) · [Clash Verge Rev](https://www.clashverge.dev/install.html)。LMM 插件适配版本见 [Pi 插件](https://github.com/TokenNotIncluded/pi-lmm-provider) 和 [DSH 插件](https://github.com/TokenNotIncluded/dsh-lmm-provider)。

本地检查:`python3 test.py`、`pwsh -NoProfile -File test.ps1`、`shellcheck *.sh`。CI 另做三平台 CLI 实装和 Debian/Alpine 实装;不把模拟测试当作桌面 GUI、Termux 真机、账号登录或代理功能实测。
2 changes: 1 addition & 1 deletion menu.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ try {
if ($PSScriptRoot -and (Test-Path (Join-Path $PSScriptRoot $name))) {
$path = Join-Path $PSScriptRoot $name
} else {
Invoke-WebRequest -UseBasicParsing "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/848c82c253b5d35712943e5a9990606e4613e7b9/$name" -OutFile $path
Invoke-WebRequest -UseBasicParsing "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/b7b066e5ca0e8a16b13c37a308419bc475cd459b/$name" -OutFile $path
}
& (Get-Process -Id $PID).Path -NoProfile -ExecutionPolicy Bypass -File $path
if ($LASTEXITCODE) { Write-Warning "Installer exited with $LASTEXITCODE" }
Expand Down
2 changes: 1 addition & 1 deletion menu.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ while true; do
if [[ -n ${BASH_SOURCE[0]:-} && -f $dir/$tool.sh ]]; then
bash "$dir/$tool.sh" </dev/tty || printf 'Installation failed.\n' >&2
else
script=$(curl -fsSL "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/848c82c253b5d35712943e5a9990606e4613e7b9/$tool.sh") || continue
script=$(curl -fsSL "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/b7b066e5ca0e8a16b13c37a308419bc475cd459b/$tool.sh") || continue
bash -c "$script" </dev/tty || printf 'Installation failed.\n' >&2
fi
done
9 changes: 7 additions & 2 deletions pi.ps1
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
$ErrorActionPreference = 'Stop'
if ($args.Count) { throw 'Usage: .\pi.ps1' }
npm.cmd install -g --ignore-scripts @earendil-works/pi-coding-agent@0.85.1
$installer = Invoke-RestMethod https://pi.dev/install.ps1
& ([scriptblock]::Create($installer + "`n" + @'
$pi = Join-Path (Get-PiBinDir) 'pi.cmd'
$version = & $pi --version
if ($LASTEXITCODE) { exit $LASTEXITCODE }
pi.cmd install npm:@tokennotincluded/pi-lmm-provider@0.1.0-alpha.1
if ($version -ne '0.85.1') { Write-Warning "Pi $version installed; LMM alpha requires 0.85.1, plugin skipped."; exit 0 }
& $pi install npm:@tokennotincluded/pi-lmm-provider@0.1.0-alpha.1
exit $LASTEXITCODE
'@))
14 changes: 10 additions & 4 deletions pi.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
set -e
[[ $# = 0 ]] || { echo 'Usage: bash pi.sh' >&2; exit 2; }
# Pin the host version required by the LMM alpha plugin.
npm install -g --ignore-scripts @earendil-works/pi-coding-agent@0.85.1
pi install npm:@tokennotincluded/pi-lmm-provider@0.1.0-alpha.1
if [[ -n ${TERMUX_VERSION:-} || ${PREFIX:-} == */com.termux/files/usr ]]; then
pkg install nodejs npm git
export TMPDIR="${TMPDIR:-${PREFIX:-/data/data/com.termux/files/usr}/tmp}"
fi
installer=$(curl -fsSL https://pi.dev/install.sh)
exec sh -c "$installer"'
pi=$(pi_installed_path); version=$("$pi" --version)
[ "$version" = 0.85.1 ] || { echo "Pi $version installed; LMM alpha requires 0.85.1, plugin skipped." >&2; exit 0; }
"$pi" install npm:@tokennotincluded/pi-lmm-provider@0.1.0-alpha.1'
39 changes: 33 additions & 6 deletions test.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,54 @@ foreach ($file in Get-ChildItem $PSScriptRoot -Filter '*.ps1') {
if ($errors.Count) { throw ($errors | Out-String) }
}
$engine = (Get-Process -Id $PID).Path
$checks = 0
function Check([string]$File,[string]$Setup,[int]$Code,[string]$Pattern,[string]$Arguments='') {
$ErrorActionPreference = 'Continue'
$path=(Join-Path $PSScriptRoot $File).Replace("'","''")
$codeText = "`$ErrorActionPreference='Stop'; `$LASTEXITCODE=0; $Setup`n& '$path' $Arguments; exit `$LASTEXITCODE"
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($codeText))
$output = & $engine -NoProfile -NonInteractive -EncodedCommand $encoded 2>&1 | Out-String
$output = & $engine -NoProfile -NonInteractive -EncodedCommand $encoded *>&1 | Out-String
if ($LASTEXITCODE -ne $Code -or $output -notmatch $Pattern) { throw "$File expected exit $Code / $Pattern, received $LASTEXITCODE : $output" }
$script:checks++
}
$piSetup = @'
$env:PI_TEST_INSTALLER = 'function Get-PiBinDir { "official-bin" }; $env:PI_VENDOR_ENV="ready"'
$env:PI_TEST_VERSION = '0.85.1'
function Invoke-RestMethod {
if ($args[0] -ne 'https://pi.dev/install.ps1') { throw 'wrong installer URL' }
return $env:PI_TEST_INSTALLER
}
function Join-Path {
param($Path,$ChildPath)
if ($Path -ne 'official-bin' -or $ChildPath -ne 'pi.cmd') { throw 'wrong installed path' }
return 'official-pi'
}
function official-pi {
if ($env:PI_VENDOR_ENV -ne 'ready') { throw 'official environment lost' }
$global:LASTEXITCODE=0
if ($args[0] -eq '--version') { $env:PI_TEST_VERSION }
else { Write-Output ($args -join '|') }
}
function Write-Warning { param($Message) Write-Output $Message }
function npm.cmd { throw 'must not replace the official installer with npm' }
function pi.cmd { throw 'must not invoke stale pi on PATH' }
'@
Check 'pi.ps1' $piSetup 0 'install\|npm:@tokennotincluded/pi-lmm-provider'
Check 'pi.ps1' ($piSetup+"`n`$env:PI_TEST_INSTALLER='Write-Output cancelled; exit 0'") 0 'cancelled'
Check 'pi.ps1' ($piSetup+"`n`$env:PI_TEST_INSTALLER='Write-Output failed; exit 9'") 9 'failed'
Check 'pi.ps1' ($piSetup+"`n`$env:PI_TEST_VERSION='0.86.1'; function official-pi { if (`$args[0] -ne '--version') { throw 'unsupported plugin must not run' }; `$env:PI_TEST_VERSION }") 0 'plugin skipped'
Check 'pi.ps1' ($piSetup+"`nfunction official-pi { if (`$args[0] -ne '--version') { throw 'plugin must not run' }; `$global:LASTEXITCODE=8 }") 8 ''
Check 'pi.ps1' ($piSetup+"`nfunction official-pi { if (`$args[0] -eq '--version') { '0.85.1'; `$global:LASTEXITCODE=0 } else { Write-Output plugin-failed; `$global:LASTEXITCODE=7 } }") 7 'plugin-failed'
Check 'pi.ps1' "function Invoke-RestMethod { throw 'download-failed' }" 1 'download-failed'
$npmFailure = @'
function npm.cmd { Write-Output 'npm failed'; $global:LASTEXITCODE=9 }
function pi.cmd { throw 'plugin must not run' }
function dsh.cmd { throw 'plugin must not run' }
'@
Check 'pi.ps1' $npmFailure 9 'npm failed'
Check 'dsh.ps1' $npmFailure 9 'npm failed'
$npmSuccess = @'
function npm.cmd { Write-Output ($args -join '|'); $global:LASTEXITCODE=0 }
function pi.cmd { Write-Output ($args -join '|'); $global:LASTEXITCODE=0 }
function dsh.cmd { Write-Output ($args -join '|'); $global:LASTEXITCODE=0 }
'@
Check 'pi.ps1' $npmSuccess 0 'install\|npm:@tokennotincluded/pi-lmm-provider'
Check 'dsh.ps1' $npmSuccess 0 'plugin\|--profile\|headless' '-Profile headless'
$upstream = @'
function Invoke-RestMethod { return 'param([string]$Version) Write-Output "upstream:$Version"' }
Expand Down Expand Up @@ -53,4 +80,4 @@ function Start-Process {
'@
Check 'cc-switch.ps1' ($msi+"`n`$env:PROCESSOR_ARCHITECTURE='AMD64'") 0 'https://test.invalid/x64'
Check 'cc-switch.ps1' ($msi+"`n`$env:PROCESSOR_ARCHITECTURE='ARM64'") 0 'https://test.invalid/arm64'
Write-Output 'PowerShell syntax and 11 command-contract checks passed.'
Write-Output "PowerShell syntax and $checks command-contract checks passed."
78 changes: 66 additions & 12 deletions test.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,21 @@
print('touch "$HOME/should-not-exist"')
sys.exit(18)
url = next(a for a in args if a.startswith('https://'))
if '/codex/install.sh' in url or url == 'https://claude.ai/install.sh':
if url == 'https://pi.dev/install.sh':
# A new prefix outside PATH: never fall back to the old `pi` stub.
entry = Path(os.environ['HOME'], "official pi's bin", 'pi')
entry.parent.mkdir()
entry.write_text('#!' + sys.executable + '\n' + "import json, os, sys\n"
+ "assert os.environ.get('PI_VENDOR_ENV') == 'ready'\n"
+ "with open(os.environ['LOG'], 'a') as f: f.write(json.dumps(['official-pi', sys.argv[1:]])+'\\n')\n"
+ "if sys.argv[1:] == ['--version']:\n"
+ " print(os.environ.get('PI_VERSION', '0.85.1')); sys.exit(int(os.environ.get('PI_VERSION_EXIT', '0')))\n"
+ "sys.exit(int(os.environ.get('COMMAND_EXIT', '0')))\n")
entry.chmod(0o755)
print('set -eu\nexport PI_VENDOR_ENV=ready\npi_installed_path() { printf "%s" "$HOME/official pi\'s bin/pi"; }')
if 'PI_INSTALLER_EXIT' in os.environ:
print('exit ' + os.environ['PI_INSTALLER_EXIT'])
elif '/codex/install.sh' in url or url == 'https://claude.ai/install.sh':
print('printf "%s\\n" "$@" > "$HOME/received-args"')
elif url.endswith('/desktop.sh'):
print(Path(os.environ['PROJECT'], 'desktop.sh').read_text())
Expand All @@ -48,6 +62,7 @@
# Record the actual guest invocation; do not fake an Android binary.
sys.exit(0)
elif name == 'npm': sys.exit(int(os.environ.get('NPM_EXIT','0')))
elif name == 'pkg': sys.exit(int(os.environ.get('PKG_EXIT','0')))
elif name == 'sudo': os.execvp(args[0],args)
else: sys.exit(int(os.environ.get('COMMAND_EXIT','0')))
'''
Expand All @@ -63,10 +78,10 @@ def setUp(self):
# Keep host package managers out of simulated platform tests.
for name in ('bash','sh','dirname','mktemp','rm','mkdir','install'):
os.symlink(shutil.which(name), self.bin/name)
for name in ('curl','npm','pi','dsh','uname','jq','tar','proot-distro','brew','cargo','sudo'):
for name in ('curl','npm','pi','dsh','uname','jq','tar','proot-distro','brew','cargo','sudo','pkg'):
self.stub(name)
self.env = dict(os.environ, HOME=str(self.home), PATH=str(self.bin), LOG=str(self.log), PROJECT=str(ROOT), REAL_JQ=shutil.which('jq'))
for key in ('TERMUX_VERSION','PREFIX','LMM_DISTRO'):
for key in ('TERMUX_VERSION','PREFIX','LMM_DISTRO','PI_VENDOR_ENV'):
self.env.pop(key,None)

def tearDown(self):
Expand All @@ -83,17 +98,52 @@ def run_script(self,name,*args,**env):
def calls(self):
return [json.loads(line) for line in self.log.read_text().splitlines()]

def test_pi_and_dsh_keep_official_commands_and_lmm_plugins(self):
self.assertEqual(self.run_script('pi.sh').returncode,0)
calls=self.calls()
self.assertIn('--ignore-scripts', calls[0][1])
self.assertEqual(calls[1],['pi',['install','npm:@tokennotincluded/pi-lmm-provider@0.1.0-alpha.1']])
def test_pi_delegates_to_official_installer_and_uses_its_environment(self):
result=self.run_script('pi.sh')
self.assertEqual(result.returncode,0,result.stderr)
self.assertEqual(self.calls(),[
['curl',['-fsSL','https://pi.dev/install.sh']],
['official-pi',['--version']],
['official-pi',['install','npm:@tokennotincluded/pi-lmm-provider@0.1.0-alpha.1']],
])

def test_pi_official_failure_or_cancellation_stops_plugin(self):
for code in ('9','0','130'):
with self.subTest(code=code):
result=self.run_script('pi.sh',PI_INSTALLER_EXIT=code)
self.assertEqual(result.returncode,int(code),result.stderr)
self.assertTrue(all(name=='curl' for name,_ in self.calls()))
shutil.rmtree(self.home/"official pi's bin")

def test_pi_new_host_is_not_downgraded_for_old_plugin(self):
result=self.run_script('pi.sh',PI_VERSION='0.86.1')
self.assertEqual(result.returncode,0,result.stderr)
self.assertIn('plugin skipped',result.stderr)
self.assertEqual([name for name,_ in self.calls()],['curl','official-pi'])

def test_pi_version_and_plugin_failures_propagate(self):
for env in ({'PI_VERSION_EXIT':'8'},{'COMMAND_EXIT':'7'}):
result=self.run_script('pi.sh',**env)
self.assertEqual(result.returncode,int(next(iter(env.values()))),result.stderr)
shutil.rmtree(self.home/"official pi's bin")

def test_pi_termux_prepares_native_dependencies_then_delegates(self):
result=self.run_script('pi.sh',TERMUX_VERSION='test',PREFIX='/data/data/com.termux/files/usr')
self.assertEqual(result.returncode,0,result.stderr)
self.assertEqual(self.calls()[0],['pkg',['install','nodejs','npm','git']])
self.assertEqual(self.calls()[1],['curl',['-fsSL','https://pi.dev/install.sh']])

def test_pi_termux_dependency_failure_stops_before_official_bootstrap(self):
result=self.run_script('pi.sh',PREFIX='/data/data/com.termux/files/usr',PKG_EXIT='7')
self.assertEqual(result.returncode,7,result.stderr)
self.assertEqual(self.calls(),[['pkg',['install','nodejs','npm','git']]])

def test_dsh_keeps_documented_commands_and_lmm_plugin(self):
self.assertEqual(self.run_script('dsh.sh','headless').returncode,0)
self.assertEqual(self.calls()[-1][1][:3],['plugin','--profile','headless'])

def test_npm_failure_never_installs_plugin(self):
for script in ('pi.sh','dsh.sh'):
self.assertEqual(self.run_script(script,NPM_EXIT='9').returncode,9)
self.assertEqual(self.run_script('dsh.sh',NPM_EXIT='9').returncode,9)
self.assertTrue(all(name=='npm' for name,_ in self.calls()))

def test_removed_flags_fail_instead_of_silently_installing(self):
Expand All @@ -109,7 +159,7 @@ def test_native_cli_delegates_and_preserves_arguments(self):
self.assertTrue(all(name=='curl' for name,_ in self.calls()))

def test_failed_download_does_not_execute_partial_script(self):
for name in ('codex.sh','claude-code.sh'):
for name in ('pi.sh','codex.sh','claude-code.sh'):
self.assertNotEqual(self.run_script(name,FAIL_DOWNLOAD='1').returncode,0)
self.assertFalse((self.home/'should-not-exist').exists())

Expand Down Expand Up @@ -190,7 +240,11 @@ def test_flat_layout_and_size_budget(self):
self.assertNotIn('@MENU_REV@',text)
self.assertNotIn('@DESKTOP_REV@',text)
for name in ('codex','claude-code','pi','dsh'):
self.assertLessEqual(len((ROOT/f'{name}.sh').read_text().splitlines()),10)
self.assertLessEqual(len((ROOT/f'{name}.sh').read_text().splitlines()),12 if name=='pi' else 10)
for ext in ('sh','ps1'):
text=(ROOT/f'pi.{ext}').read_text()
self.assertIn(f'https://pi.dev/install.{ext}',text)
self.assertNotIn('@earendil-works/pi-coding-agent',text)

if __name__=='__main__':
unittest.main(verbosity=2)
Loading