From 1489303eb5c279156cf08243d1bc126a11b40ba3 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 13 Oct 2026 01:58:21 +0800 Subject: [PATCH 1/4] feat: add cross-platform Magpie installers and offline checks --- .github/workflows/magpie-prepare.yml | 84 +++++++++++++ .github/workflows/magpie.yml | 31 +++++ MAGPIE.md | 57 +++++++++ magpie.ps1 | 55 ++++++++ magpie.sh | 16 +++ test-magpie.ps1 | 99 +++++++++++++++ test-magpie.py | 181 +++++++++++++++++++++++++++ 7 files changed, 523 insertions(+) create mode 100644 .github/workflows/magpie-prepare.yml create mode 100644 .github/workflows/magpie.yml create mode 100644 MAGPIE.md create mode 100644 magpie.ps1 create mode 100755 magpie.sh create mode 100644 test-magpie.ps1 create mode 100644 test-magpie.py diff --git a/.github/workflows/magpie-prepare.yml b/.github/workflows/magpie-prepare.yml new file mode 100644 index 0000000..34e542f --- /dev/null +++ b/.github/workflows/magpie-prepare.yml @@ -0,0 +1,84 @@ +name: Prepare Magpie menu update +on: + push: + branches: [feat/magpie-installers] +permissions: + contents: read +jobs: + prepare: + if: github.repository == 'TokenNotIncluded/lmm-scripts' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + - name: Existing offline contracts before menu changes + run: | + python3 test.py + python3 test-ai-tools.py + - name: Patch menus, existing expectations and README + env: + INSTALLER_REV: ${{ github.sha }} + run: | + python3 - <<'PY' + from pathlib import Path + import os + import re + + revision = os.environ['INSTALLER_REV'] + assert re.fullmatch(r'[0-9a-f]{40}', revision) + + def replace(path, old, new): + file = Path(path) + text = file.read_text() + count = text.count(old) + if count != 1: + raise SystemExit(f'{path}: expected one match, got {count}: {old!r}') + file.write_text(text.replace(old, new)) + + replace('menu.sh', 'cherry-studio ollama uv)', 'cherry-studio ollama uv magpie)') + replace('menu.sh', 'tools=(pi dsh lmm codex claude-code codewhale);', 'tools=(pi dsh lmm codex claude-code codewhale magpie);') + replace('menu.sh', ' revision=873bb8de2d9af00fb2a5fbb592b3da094e665271', ' revision=' + revision) + replace('menu.sh', ' case "$tool" in pi|dsh|lmm|codex|claude-code|cc-switch|clash-verge-rev|codewhale|opencode) ;; *) revision=main;; esac\n', '') + replace('menu.sh', '/$tool.sh") || continue', '/$tool.sh") || { printf \'Could not download %s.\\n\' "$tool" >&2; continue; }') + replace('menu.ps1', "'cherry-studio','ollama','uv')", "'cherry-studio','ollama','uv','magpie')") + replace('menu.ps1', "$revision = '873bb8de2d9af00fb2a5fbb592b3da094e665271'", "$revision = '" + revision + "'") + replace('menu.ps1', " if ($tool -notin @('pi','dsh','lmm','codex','claude-code','cc-switch','clash-verge-rev','codewhale','opencode')) { $revision = 'main' }\n", '') + + # Existing contracts retain their original scope; only the menu expectations change. + if Path('test.py').exists(): + replace('test.py', "result.stdout.splitlines(),['pi','dsh','lmm','codex','claude-code','codewhale']", "result.stdout.splitlines(),['pi','dsh','lmm','codex','claude-code','codewhale','magpie']") + if Path('test-ai-tools.py').exists(): + replace('test-ai-tools.py', 'self.assertEqual(set(entries[9:]),set(ALL))', "self.assertEqual(set(entries[9:]),set(ALL) | {'magpie'})") + if Path('README.md').exists(): + replace('README.md', '## 更多 AI 工具', '''## Magpie + + 菜单第 **24** 项(Termux 第 **7** 项)安装或更新 [Magpie](https://github.com/yetone/magpie),原有编号不变。 + + - Linux / macOS / Termux:`bash magpie.sh`,复用官方安装器;Linux 自动选择桌面或终端版。 + - Windows x64 / ARM64:`.\\magpie.ps1`,安装官方最新正式发行包并校验 SHA-256;`-Cli` 选择终端版。 + - 重复运行同一脚本即可更新。不自动启动、登录、写入 API Key 或更改供应商配置。 + + 单独下载的菜单统一使用同一个固定脚本提交,不再混用旧版本和 `main`;`LMM_SCRIPTS_REV` 仍可显式覆盖。 + 安装目录、上游选项、平台条件和测试说明见 [MAGPIE.md](MAGPIE.md)。 + + ## 更多 AI 工具''') + PY + - name: Offline regression checks + run: | + python3 test.py + python3 test-ai-tools.py + python3 test-magpie.py + shellcheck *.sh + - name: PowerShell offline checks + shell: pwsh + run: ./test-magpie.ps1 + - name: Commit checked menu update + run: | + rm .github/workflows/magpie-prepare.yml + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add menu.sh menu.ps1 README.md test.py test-ai-tools.py .github/workflows/magpie-prepare.yml + git commit -m 'feat: add Magpie menus and pin all wrapper downloads' + git push origin HEAD:feat/magpie-installers diff --git a/.github/workflows/magpie.yml b/.github/workflows/magpie.yml new file mode 100644 index 0000000..2cdce17 --- /dev/null +++ b/.github/workflows/magpie.yml @@ -0,0 +1,31 @@ +name: Magpie offline checks +on: + pull_request: + paths: + - 'magpie.*' + - 'menu.*' + - 'test-magpie.*' + - '.github/workflows/magpie.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + contracts: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 5 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + - name: Bash installer and menu contracts + if: runner.os != 'Windows' + run: python3 test-magpie.py + - name: PowerShell contracts + shell: pwsh + run: ./test-magpie.ps1 + - name: Windows PowerShell 5.1 contracts + if: runner.os == 'Windows' + shell: powershell + run: .\test-magpie.ps1 diff --git a/MAGPIE.md b/MAGPIE.md new file mode 100644 index 0000000..4aa8eaa --- /dev/null +++ b/MAGPIE.md @@ -0,0 +1,57 @@ +# Magpie + +Install or update [yetone/magpie](https://github.com/yetone/magpie). +The desktop menu entry is **24**; the Termux entry is **7**. Existing entries keep their numbers. + +```sh +bash magpie.sh +bash magpie.sh update +MAGPIE_CLI=1 bash magpie.sh # Terminal build on Linux +MAGPIE_BIN_DIR="$HOME/bin" bash magpie.sh +``` + +```powershell +.\magpie.ps1 +.\magpie.ps1 update +.\magpie.ps1 -Cli +.\magpie.ps1 -BinDir "$HOME\bin" +``` + +Both files work independently. Use `help` for local usage without a download. + +## Platforms + +| Platform | Installation | +| --- | --- | +| macOS Intel / Apple silicon | Official installer: signed app in `/Applications` or `~/Applications`, with a command link. | +| Linux x64 / ARM64 | Official installer: desktop build when WebKitGTK 4.1 is available, otherwise terminal build. `MAGPIE_CLI=1` requests the terminal build. | +| Termux | Official installer: Android-native terminal build in `$PREFIX/bin`. The inspected release provides ARM64; other architectures stop if no matching asset exists. No Linux binary or proot fallback. | +| Windows x64 / ARM64 | Exact matching executable from the latest stable GitHub release, installed as `magpie.exe` in `~/.local/bin`. `-Cli` or a nonempty `MAGPIE_CLI` selects the terminal build. | + +On Unix, upstream selects the package, checks its hash and reports missing platform dependencies. No Node.js, Go, Homebrew or system package manager is required by this wrapper. Linux GUI and Windows GUI dependencies still follow upstream requirements. This does not promise compatibility with every Linux distribution or old OS release. + +`MAGPIE_BIN_DIR` changes the command directory on both entry points. Windows also accepts `-BinDir`. The wrapper does not change persistent PATH settings. Run the printed full path or add that directory to PATH yourself. + +The Unix entry finishes downloading `https://usemagpie.ai/install.sh` before it executes the response and forwards arguments unchanged. Upstream options such as `--proxy` and `--mirror` remain upstream options, not an LMM proxy framework. Environment proxy settings must also allow the initial installer download. The script requires Bash and curl; the upstream installer handles its other dependencies. + +Windows uses the exact asset name, release tag, repository URL and GitHub's SHA-256 digest. Missing, ambiguous or invalid metadata stops installation. A failed download or hash check does not replace an existing executable. A successful update stages the new binary on the same volume and uses an atomic file replacement. Close Magpie before an update if Windows reports that the executable is in use; the installer does not kill running applications. + +Installation does not launch Magpie, sign in, save API keys or overwrite provider settings. Configure providers in Magpie after installation. Magpie's own account discovery and privacy settings apply when you run it; see its [README](https://github.com/yetone/magpie#quick-start). + +## Menu downloads + +Both menus prefer adjacent local scripts. Without them, all tools use one immutable `lmm-scripts` commit, including the latest merged Codewhale fix at the time this change was made. `LMM_SCRIPTS_REV` remains an explicit override for testing another revision. The pin applies to the wrapper scripts; upstream software installers still select their current releases. + +## Checks + +```sh +python3 test-magpie.py +``` + +```powershell +.\test-magpie.ps1 +``` + +The tests use temporary directories and fake downloads. They cover argument and environment forwarding, interrupted downloads, exit status, menu numbering, local and downloaded menus, Windows architecture selection, SHA-256 validation and preservation of an old installation. They do not install or launch the real application. CI runs these checks on Linux, macOS and Windows, including Windows PowerShell 5.1. A passing fixture test is not a real-device GUI test. + +Upstream inspected: the [official Unix installer](https://github.com/yetone/magpie/blob/main/site/public/install.sh) and [v0.1.1215 assets](https://github.com/yetone/magpie/releases/tag/v0.1.1215), published on 2026-10-12. Release selection is dynamic, not pinned to that software version. diff --git a/magpie.ps1 b/magpie.ps1 new file mode 100644 index 0000000..e4326ff --- /dev/null +++ b/magpie.ps1 @@ -0,0 +1,55 @@ +param( + [ValidateSet('install', 'update', 'help')][string]$Action = 'install', + [switch]$Cli, + [string]$BinDir = $env:MAGPIE_BIN_DIR +) +$ErrorActionPreference = 'Stop' +if ($args.Count) { throw 'Usage: .\magpie.ps1 [install|update|help] [-Cli] [-BinDir PATH]' } +if ($Action -eq 'help') { + Write-Host 'Usage: .\magpie.ps1 [install|update|help] [-Cli] [-BinDir PATH]' + Write-Host 'Windows x64/ARM64. Installs the official release without starting it or changing your configuration.' + return +} +if ($env:OS -ne 'Windows_NT') { throw 'Use magpie.sh on Linux, macOS or Termux.' } +# The native architecture takes priority over an emulated PowerShell process. +$machine = $env:PROCESSOR_ARCHITEW6432 +if (-not $machine) { $machine = $env:PROCESSOR_ARCHITECTURE } +$arch = switch ($machine) { + 'AMD64' { 'amd64' } + 'ARM64' { 'arm64' } + default { throw "Unsupported Windows architecture: $machine" } +} +$flavor = if ($Cli -or $env:MAGPIE_CLI) { '-cli' } else { '' } +$name = "magpie$flavor-windows-$arch.exe" +[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 +$release = Invoke-RestMethod -UseBasicParsing 'https://api.github.com/repos/yetone/magpie/releases/latest' +if ($release.draft -or $release.prerelease -or -not $release.tag_name) { throw 'Expected an official stable Magpie release.' } +$assets = @($release.assets | Where-Object { $_.name -ceq $name }) +if ($assets.Count -ne 1) { throw "The latest release has no unique $name asset." } +$asset = $assets[0] +$url = 'https://github.com/yetone/magpie/releases/download/' + [uri]::EscapeDataString($release.tag_name) + '/' + $name +if ($asset.browser_download_url -cne $url) { throw 'Unexpected Magpie download URL.' } +if ($asset.digest -cnotmatch '^sha256:[0-9a-f]{64}$') { throw 'The release has no valid SHA-256 digest; nothing was installed.' } +$expected = $asset.digest.Substring(7) +if (-not $BinDir) { $BinDir = Join-Path $HOME '.local\bin' } +$BinDir = [IO.Path]::GetFullPath($BinDir) +New-Item -ItemType Directory -Force -Path $BinDir | Out-Null +# Stage on the destination volume so replacing an existing binary is atomic. +$work = Join-Path $BinDir ('.magpie-install-' + [guid]::NewGuid()) +try { + New-Item -ItemType Directory -Path $work | Out-Null + $download = Join-Path $work $name + Invoke-WebRequest -UseBasicParsing $url -OutFile $download + $actual = (Get-FileHash -LiteralPath $download -Algorithm SHA256).Hash + if ($actual -ine $expected) { throw 'Magpie SHA-256 mismatch; the existing installation was not changed.' } + $target = Join-Path $BinDir 'magpie.exe' + if ([IO.File]::Exists($target)) { + [IO.File]::Replace($download, $target, $null) + } else { + [IO.File]::Move($download, $target) + } + Write-Host "Installed magpie $($release.tag_name): $target" + Write-Host 'Open it to configure providers. Add the install directory to PATH to use magpie from a terminal.' +} finally { + Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/magpie.sh b/magpie.sh new file mode 100755 index 0000000..28336b2 --- /dev/null +++ b/magpie.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail +case "${1:-install}" in + install|update) if [[ $# -gt 0 ]]; then shift; fi;; + help|-h|--help) + printf '%s\n' 'Usage: bash magpie.sh [install|update] [official installer options]' \ + 'Linux, macOS and Termux: use the official installer.' \ + 'MAGPIE_CLI=1 selects the terminal build on Linux.' \ + 'MAGPIE_BIN_DIR changes the command directory. No login or configuration is performed.' + exit 0;; + -*) ;; # Let the official installer validate its own options. + *) echo 'Usage: bash magpie.sh [install|update] [official installer options]' >&2; exit 2;; +esac +# Finish the download before executing anything; never execute a partial response. +script=$(curl -fsSL https://usemagpie.ai/install.sh) +sh -c "$script" -- "$@" diff --git a/test-magpie.ps1 b/test-magpie.ps1 new file mode 100644 index 0000000..b2f0ea6 --- /dev/null +++ b/test-magpie.ps1 @@ -0,0 +1,99 @@ +# Offline fixtures only: no network, package install, GUI launch or user config. +$ErrorActionPreference = 'Stop' +foreach ($name in @('magpie.ps1', 'menu.ps1', 'test-magpie.ps1')) { + $tokens = $null; $errors = $null + [void][Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot $name), [ref]$tokens, [ref]$errors) + if ($errors.Count) { throw ($errors | Out-String) } +} +$root = Join-Path ([IO.Path]::GetTempPath()) ('magpie-test-' + [guid]::NewGuid()) +$keys = @('OS', 'PROCESSOR_ARCHITEW6432', 'PROCESSOR_ARCHITECTURE', 'MAGPIE_CLI', 'MAGPIE_BIN_DIR') +$saved = @{} +foreach ($key in $keys) { $saved[$key] = [Environment]::GetEnvironmentVariable($key) } +$checks = 0 +$script:bytes = [Text.Encoding]::UTF8.GetBytes('magpie offline fixture') +$sha = [Security.Cryptography.SHA256]::Create() +try { $script:digest = ([BitConverter]::ToString($sha.ComputeHash($script:bytes))).Replace('-', '').ToLowerInvariant() } +finally { $sha.Dispose() } +function Assert($Condition, [string]$Message) { if (-not $Condition) { throw $Message } } +function Invoke-RestMethod { + param($Uri, [switch]$UseBasicParsing) + $script:requests++ + Assert ($Uri -ceq 'https://api.github.com/repos/yetone/magpie/releases/latest') 'Wrong release endpoint' + if ($script:mode -eq 'release-failure') { throw 'release-failure' } + $asset = [pscustomobject]@{ + name = $script:assetName + browser_download_url = "https://github.com/yetone/magpie/releases/download/v1.2.3/$script:assetName" + digest = 'sha256:' + $script:digest + } + switch ($script:mode) { + 'foreign' { $asset.browser_download_url = 'https://evil.invalid/magpie.exe' } + 'wrong-tag' { $asset.browser_download_url = $asset.browser_download_url.Replace('v1.2.3', 'v9.9.9') } + 'missing-hash' { $asset.digest = $null } + 'bad-hash' { $asset.digest = 'sha256:' + ('0' * 64) } + } + $assets = @($asset) + if ($script:mode -eq 'missing') { $assets = @() } + if ($script:mode -eq 'duplicate') { $assets = @($asset, $asset) } + [pscustomobject]@{ tag_name = 'v1.2.3'; draft = ($script:mode -eq 'draft'); prerelease = ($script:mode -eq 'prerelease'); assets = $assets } +} +function Invoke-WebRequest { + param($Uri, $OutFile, [switch]$UseBasicParsing) + $script:downloads++ + Assert ($Uri -ceq "https://github.com/yetone/magpie/releases/download/v1.2.3/$script:assetName") 'Wrong asset URL' + [IO.File]::WriteAllBytes($OutFile, $script:bytes) + if ($script:mode -eq 'download-failure') { throw 'download-failure' } +} +function Start-Process { throw 'Installer must not start the application' } +function Case([string]$Mode = 'success', [string]$Arch = 'AMD64', [string]$Native = '', [switch]$Cli, [switch]$Fresh, [string]$Action = 'install') { + $script:mode = $Mode; $script:requests = 0; $script:downloads = 0 + $env:OS = 'Windows_NT'; $env:PROCESSOR_ARCHITECTURE = $Arch; $env:PROCESSOR_ARCHITEW6432 = $Native + $env:MAGPIE_CLI = ''; $env:MAGPIE_BIN_DIR = '' + if ($Mode -eq 'non-windows') { $env:OS = 'Linux' } + if ($Mode -eq 'env-cli') { $env:MAGPIE_CLI = '1' } + $cpu = if ($Native) { $Native } else { $Arch } + $cpu = if ($cpu -eq 'ARM64') { 'arm64' } else { 'amd64' } + $flavor = if ($Cli -or $env:MAGPIE_CLI) { '-cli' } else { '' } + $script:assetName = "magpie$flavor-windows-$cpu.exe" + $directory = Join-Path $root ("case with spaces " + $script:checks) + New-Item -ItemType Directory -Path $directory -Force | Out-Null + $target = Join-Path $directory 'magpie.exe' + if (-not $Fresh) { [IO.File]::WriteAllText($target, 'old-version') } + $failure = $null + $arguments = @{ Action = $Action; BinDir = $directory; Cli = $Cli } + if ($Mode -eq 'env-directory') { $env:MAGPIE_BIN_DIR = $directory; $arguments.Remove('BinDir') } + try { & (Join-Path $PSScriptRoot 'magpie.ps1') @arguments } + catch { $failure = $_ } + $success = $Mode -in @('success', 'env-cli', 'env-directory') + if ($Action -eq 'help') { + Assert (-not $failure -and $script:requests -eq 0) 'Help used the network or failed' + Assert ([IO.File]::ReadAllText($target) -eq 'old-version') 'Help changed the installation' + } elseif ($success) { + Assert (-not $failure) "Install failed: $failure" + Assert ($script:requests -eq 1 -and $script:downloads -eq 1) 'Unexpected downloads' + Assert ((Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash -ieq $script:digest) 'Wrong installed bytes' + } else { + Assert ($null -ne $failure) "$Mode should fail" + Assert ([IO.File]::ReadAllText($target) -eq 'old-version') "$Mode changed the existing installation" + if ($Mode -notin @('bad-hash', 'download-failure')) { Assert ($script:downloads -eq 0) "$Mode downloaded an executable" } + if ($Mode -in @('unsupported', 'non-windows')) { Assert ($script:requests -eq 0) "$Mode used the network" } + } + Assert (@(Get-ChildItem -LiteralPath $directory -Force -Filter '.magpie-install-*').Count -eq 0) 'Staging directory leaked' + $script:checks++ +} +try { + Case -Fresh + Case -Action update + Case -Arch ARM64 + Case -Arch x86 -Native ARM64 + Case -Cli + Case -Mode env-cli + Case -Mode env-directory + Case -Action help + Case -Mode unsupported -Arch x86 + Case -Mode non-windows + foreach ($mode in @('release-failure', 'missing', 'duplicate', 'foreign', 'wrong-tag', 'missing-hash', 'bad-hash', 'draft', 'prerelease', 'download-failure')) { Case -Mode $mode } + Write-Host "Passed $checks Magpie PowerShell cases." +} finally { + foreach ($key in $keys) { [Environment]::SetEnvironmentVariable($key, $saved[$key]) } + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/test-magpie.py b/test-magpie.py new file mode 100644 index 0000000..8b4776f --- /dev/null +++ b/test-magpie.py @@ -0,0 +1,181 @@ +"""Offline Magpie and menu checks. No software is installed and no network is used.""" +import errno +import json +import os +from pathlib import Path +import pty +import re +import select +import shutil +import signal +import subprocess +import sys +import tempfile +import time +import unittest + +ROOT = Path(__file__).resolve().parent +ORIGINAL = 'pi dsh lmm codex claude-code cc-switch clash-verge-rev codewhale opencode cursor-cli grok-build gemini qwen-code kimi codebuddy hermes openclaw astrbot aider cursor cherry-studio ollama uv'.split() +TERMUX = 'pi dsh lmm codex claude-code codewhale'.split() +CURL = r'''import json, os, sys +from pathlib import Path +args = sys.argv[1:] +url = next(arg for arg in args if arg.startswith('https://')) +with open(os.environ['LOG'], 'a') as log: log.write(json.dumps(url)+'\n') +if os.environ.get('FAIL_DOWNLOAD'): + print('touch "$HOME/partial-executed"') + sys.exit(18) +if 'raw.githubusercontent.com' in url: + print(Path(os.environ['ROOT'], 'magpie.sh').read_text()) +else: + assert url == 'https://usemagpie.ai/install.sh' + print('printf "%s\\n" "$@" > "$HOME/upstream-args"') + print('printf "%s\\n" "${MAGPIE_CLI:-}" "${MAGPIE_BIN_DIR:-}" "${TERMUX_VERSION:-}" "${PREFIX:-}" > "$HOME/upstream-env"') + print('exit '+os.environ.get('INSTALL_EXIT','0')) +''' + + +class MagpieTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.home = Path(self.tmp.name) + self.bin = self.home / 'bin' + self.bin.mkdir() + for name in ('bash', 'sh', 'dirname'): + os.symlink(shutil.which(name), self.bin / name) + curl = self.bin / 'curl' + curl.write_text('#!' + sys.executable + '\n' + CURL) + curl.chmod(0o755) + self.log = self.home / 'calls' + self.log.touch() + self.env = dict(os.environ, HOME=str(self.home), PATH=str(self.bin), LOG=str(self.log), ROOT=str(ROOT)) + for name in ('TERMUX_VERSION', 'PREFIX', 'MAGPIE_CLI', 'MAGPIE_BIN_DIR', 'LMM_SCRIPTS_REV', 'FAIL_DOWNLOAD', 'INSTALL_EXIT'): + self.env.pop(name, None) + + def tearDown(self): + self.tmp.cleanup() + + def run_script(self, *args, **env): + return subprocess.run([shutil.which('bash'), str(ROOT / 'magpie.sh'), *args], + env=self.env | env, capture_output=True, text=True, timeout=10) + + def calls(self): + return [json.loads(line) for line in self.log.read_text().splitlines()] + + def test_install_update_and_exact_argument_forwarding(self): + for action in ((), ('install',), ('update',)): + result = self.run_script(*action) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual((self.home / 'upstream-args').read_text().strip(), '') + args = ('--proxy', 'http://localhost:7890', '--mirror', 'https://example.invalid/a b/') + result = self.run_script('install', *args) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual((self.home / 'upstream-args').read_text().splitlines(), list(args)) + + def test_partial_download_is_not_executed(self): + self.assertEqual(self.run_script(FAIL_DOWNLOAD='1').returncode, 18) + self.assertFalse((self.home / 'partial-executed').exists()) + self.assertFalse((self.home / 'upstream-args').exists()) + + def test_upstream_failure_and_cancellation_propagate(self): + for code in (9, 130): + self.assertEqual(self.run_script(INSTALL_EXIT=str(code)).returncode, code) + + def test_termux_and_custom_install_environment_are_preserved(self): + values = ('1', str(self.home / 'custom bin'), 'test', '/data/data/com.termux/files/usr') + result = self.run_script(**dict(zip(('MAGPIE_CLI', 'MAGPIE_BIN_DIR', 'TERMUX_VERSION', 'PREFIX'), values))) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual((self.home / 'upstream-env').read_text().splitlines(), list(values)) + + def test_help_and_invalid_action_do_not_download(self): + for action in ('help', '--help', '-h'): + self.assertEqual(self.run_script(action).returncode, 0) + self.assertEqual(self.run_script('not-an-action').returncode, 2) + self.assertEqual(self.calls(), []) + + def test_menu_numbers_and_platform_entries(self): + script = (ROOT / 'menu.sh').read_text().split('while true; do')[0] + script += 'printf "%s\\n" "${tools[@]}"\n' + for env, expected in (({}, ORIGINAL + ['magpie']), ({'TERMUX_VERSION': 'test'}, TERMUX + ['magpie']), + ({'PREFIX': '/data/data/com.termux/files/usr'}, TERMUX + ['magpie'])): + result = subprocess.run([shutil.which('bash'), '-c', script], env=self.env | env, + capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.splitlines(), expected) + powershell = (ROOT / 'menu.ps1').read_text() + entries = re.search(r'\$tools = @\(([^\n]+)\)', powershell).group(1) + self.assertEqual(re.findall(r"'([^']+)'", entries), ORIGINAL + ['magpie']) + self.assertTrue((ROOT / 'magpie.ps1').is_file()) + + def test_remote_menus_share_one_immutable_revision(self): + shell = (ROOT / 'menu.sh').read_text() + powershell = (ROOT / 'menu.ps1').read_text() + revision = re.search(r' revision=([0-9a-f]{40})\n', shell).group(1) + self.assertIn("$revision = '" + revision + "'", powershell) + self.assertNotIn('873bb8de2d9af00fb2a5fbb592b3da094e665271', shell + powershell) + self.assertNotIn('revision=main', shell) + self.assertNotIn("$revision = 'main'", powershell) + + def menu(self, inputs, local=False, **env): + folder = self.home / 'menu copy' + folder.mkdir(exist_ok=True) + menu = folder / 'menu.sh' + shutil.copyfile(ROOT / 'menu.sh', menu) + if local: + (folder / 'magpie.sh').write_text('printf "local-magpie\\n"; exit 9\n') + pid, fd = pty.fork() + if pid == 0: + os.execve(shutil.which('bash'), ['bash', str(menu)], self.env | env) + chunks = [] + try: + os.write(fd, inputs.encode()) + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + ready, _, _ = select.select([fd], [], [], 0.1) + if ready: + try: + data = os.read(fd, 65536) + except OSError as exc: + if exc.errno != errno.EIO: + raise + break + if not data: + break + chunks.append(data) + else: + self.fail('Menu did not exit within the test limit.') + finally: + os.close(fd) + done, status = os.waitpid(pid, os.WNOHANG) + if not done: + os.kill(pid, signal.SIGKILL) + _, status = os.waitpid(pid, 0) + self.assertEqual(os.waitstatus_to_exitcode(status), 0, b''.join(chunks).decode()) + return b''.join(chunks).decode() + + def test_standalone_menu_downloads_magpie_at_selected_revision(self): + self.menu('invalid\n99\n24\n0\n', LMM_SCRIPTS_REV='reviewed-test') + self.assertEqual(self.calls(), [ + 'https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/reviewed-test/magpie.sh', + 'https://usemagpie.ai/install.sh', + ]) + + def test_termux_menu_installs_magpie(self): + self.menu('7\n0\n', TERMUX_VERSION='test') + self.assertTrue(self.calls()[0].endswith('/magpie.sh')) + self.assertEqual((self.home / 'upstream-env').read_text().splitlines()[2], 'test') + + def test_local_menu_keeps_working_after_installer_failure(self): + output = self.menu('24\n0\n', local=True) + self.assertIn('local-magpie', output) + self.assertIn('Installation failed.', output) + self.assertEqual(self.calls(), []) + + def test_menu_download_failure_does_not_execute_partial_script(self): + output = self.menu('24\n0\n', FAIL_DOWNLOAD='1') + self.assertIn('Could not download magpie.', output) + self.assertFalse((self.home / 'partial-executed').exists()) + + +if __name__ == '__main__': + unittest.main(verbosity=2) From 6642b1dd6e0a77038ab7adb81b213186b1960e8e Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 13 Oct 2026 02:02:37 +0800 Subject: [PATCH 2/4] test: isolate Magpie fixtures across PowerShell script scopes --- test-magpie.ps1 | 47 +++++++++++++++++++++++++---------------------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/test-magpie.ps1 b/test-magpie.ps1 index b2f0ea6..044d6c7 100644 --- a/test-magpie.ps1 +++ b/test-magpie.ps1 @@ -10,42 +10,44 @@ $keys = @('OS', 'PROCESSOR_ARCHITEW6432', 'PROCESSOR_ARCHITECTURE', 'MAGPIE_CLI' $saved = @{} foreach ($key in $keys) { $saved[$key] = [Environment]::GetEnvironmentVariable($key) } $checks = 0 -$script:bytes = [Text.Encoding]::UTF8.GetBytes('magpie offline fixture') +# Mock functions are called from another script scope. Keep their data explicit. +$global:LmmMagpieFixture = @{} +$global:LmmMagpieFixture.bytes = [Text.Encoding]::UTF8.GetBytes('magpie offline fixture') $sha = [Security.Cryptography.SHA256]::Create() -try { $script:digest = ([BitConverter]::ToString($sha.ComputeHash($script:bytes))).Replace('-', '').ToLowerInvariant() } +try { $global:LmmMagpieFixture.digest = ([BitConverter]::ToString($sha.ComputeHash($global:LmmMagpieFixture.bytes))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() } function Assert($Condition, [string]$Message) { if (-not $Condition) { throw $Message } } function Invoke-RestMethod { param($Uri, [switch]$UseBasicParsing) - $script:requests++ + $global:LmmMagpieFixture.requests++ Assert ($Uri -ceq 'https://api.github.com/repos/yetone/magpie/releases/latest') 'Wrong release endpoint' - if ($script:mode -eq 'release-failure') { throw 'release-failure' } + if ($global:LmmMagpieFixture.mode -eq 'release-failure') { throw 'release-failure' } $asset = [pscustomobject]@{ - name = $script:assetName - browser_download_url = "https://github.com/yetone/magpie/releases/download/v1.2.3/$script:assetName" - digest = 'sha256:' + $script:digest + name = $global:LmmMagpieFixture.assetName + browser_download_url = "https://github.com/yetone/magpie/releases/download/v1.2.3/$($global:LmmMagpieFixture.assetName)" + digest = 'sha256:' + $global:LmmMagpieFixture.digest } - switch ($script:mode) { + switch ($global:LmmMagpieFixture.mode) { 'foreign' { $asset.browser_download_url = 'https://evil.invalid/magpie.exe' } 'wrong-tag' { $asset.browser_download_url = $asset.browser_download_url.Replace('v1.2.3', 'v9.9.9') } 'missing-hash' { $asset.digest = $null } 'bad-hash' { $asset.digest = 'sha256:' + ('0' * 64) } } $assets = @($asset) - if ($script:mode -eq 'missing') { $assets = @() } - if ($script:mode -eq 'duplicate') { $assets = @($asset, $asset) } - [pscustomobject]@{ tag_name = 'v1.2.3'; draft = ($script:mode -eq 'draft'); prerelease = ($script:mode -eq 'prerelease'); assets = $assets } + if ($global:LmmMagpieFixture.mode -eq 'missing') { $assets = @() } + if ($global:LmmMagpieFixture.mode -eq 'duplicate') { $assets = @($asset, $asset) } + [pscustomobject]@{ tag_name = 'v1.2.3'; draft = ($global:LmmMagpieFixture.mode -eq 'draft'); prerelease = ($global:LmmMagpieFixture.mode -eq 'prerelease'); assets = $assets } } function Invoke-WebRequest { param($Uri, $OutFile, [switch]$UseBasicParsing) - $script:downloads++ - Assert ($Uri -ceq "https://github.com/yetone/magpie/releases/download/v1.2.3/$script:assetName") 'Wrong asset URL' - [IO.File]::WriteAllBytes($OutFile, $script:bytes) - if ($script:mode -eq 'download-failure') { throw 'download-failure' } + $global:LmmMagpieFixture.downloads++ + Assert ($Uri -ceq "https://github.com/yetone/magpie/releases/download/v1.2.3/$($global:LmmMagpieFixture.assetName)") 'Wrong asset URL' + [IO.File]::WriteAllBytes($OutFile, $global:LmmMagpieFixture.bytes) + if ($global:LmmMagpieFixture.mode -eq 'download-failure') { throw 'download-failure' } } function Start-Process { throw 'Installer must not start the application' } function Case([string]$Mode = 'success', [string]$Arch = 'AMD64', [string]$Native = '', [switch]$Cli, [switch]$Fresh, [string]$Action = 'install') { - $script:mode = $Mode; $script:requests = 0; $script:downloads = 0 + $global:LmmMagpieFixture.mode = $Mode; $global:LmmMagpieFixture.requests = 0; $global:LmmMagpieFixture.downloads = 0 $env:OS = 'Windows_NT'; $env:PROCESSOR_ARCHITECTURE = $Arch; $env:PROCESSOR_ARCHITEW6432 = $Native $env:MAGPIE_CLI = ''; $env:MAGPIE_BIN_DIR = '' if ($Mode -eq 'non-windows') { $env:OS = 'Linux' } @@ -53,7 +55,7 @@ function Case([string]$Mode = 'success', [string]$Arch = 'AMD64', [string]$Nativ $cpu = if ($Native) { $Native } else { $Arch } $cpu = if ($cpu -eq 'ARM64') { 'arm64' } else { 'amd64' } $flavor = if ($Cli -or $env:MAGPIE_CLI) { '-cli' } else { '' } - $script:assetName = "magpie$flavor-windows-$cpu.exe" + $global:LmmMagpieFixture.assetName = "magpie$flavor-windows-$cpu.exe" $directory = Join-Path $root ("case with spaces " + $script:checks) New-Item -ItemType Directory -Path $directory -Force | Out-Null $target = Join-Path $directory 'magpie.exe' @@ -65,17 +67,17 @@ function Case([string]$Mode = 'success', [string]$Arch = 'AMD64', [string]$Nativ catch { $failure = $_ } $success = $Mode -in @('success', 'env-cli', 'env-directory') if ($Action -eq 'help') { - Assert (-not $failure -and $script:requests -eq 0) 'Help used the network or failed' + Assert (-not $failure -and $global:LmmMagpieFixture.requests -eq 0) 'Help used the network or failed' Assert ([IO.File]::ReadAllText($target) -eq 'old-version') 'Help changed the installation' } elseif ($success) { Assert (-not $failure) "Install failed: $failure" - Assert ($script:requests -eq 1 -and $script:downloads -eq 1) 'Unexpected downloads' - Assert ((Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash -ieq $script:digest) 'Wrong installed bytes' + Assert ($global:LmmMagpieFixture.requests -eq 1 -and $global:LmmMagpieFixture.downloads -eq 1) 'Unexpected downloads' + Assert ((Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash -ieq $global:LmmMagpieFixture.digest) 'Wrong installed bytes' } else { Assert ($null -ne $failure) "$Mode should fail" Assert ([IO.File]::ReadAllText($target) -eq 'old-version') "$Mode changed the existing installation" - if ($Mode -notin @('bad-hash', 'download-failure')) { Assert ($script:downloads -eq 0) "$Mode downloaded an executable" } - if ($Mode -in @('unsupported', 'non-windows')) { Assert ($script:requests -eq 0) "$Mode used the network" } + if ($Mode -notin @('bad-hash', 'download-failure')) { Assert ($global:LmmMagpieFixture.downloads -eq 0) "$Mode downloaded an executable" } + if ($Mode -in @('unsupported', 'non-windows')) { Assert ($global:LmmMagpieFixture.requests -eq 0) "$Mode used the network" } } Assert (@(Get-ChildItem -LiteralPath $directory -Force -Filter '.magpie-install-*').Count -eq 0) 'Staging directory leaked' $script:checks++ @@ -96,4 +98,5 @@ try { } finally { foreach ($key in $keys) { [Environment]::SetEnvironmentVariable($key, $saved[$key]) } Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue + Remove-Variable -Name LmmMagpieFixture -Scope Global -ErrorAction SilentlyContinue } From 86391c6e604bdbbf1231770529c47d508eca7d45 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 13 Oct 2026 02:04:43 +0800 Subject: [PATCH 3/4] fix: preserve null backup argument during atomic Magpie updates --- magpie.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/magpie.ps1 b/magpie.ps1 index e4326ff..4bf3874 100644 --- a/magpie.ps1 +++ b/magpie.ps1 @@ -44,7 +44,8 @@ try { if ($actual -ine $expected) { throw 'Magpie SHA-256 mismatch; the existing installation was not changed.' } $target = Join-Path $BinDir 'magpie.exe' if ([IO.File]::Exists($target)) { - [IO.File]::Replace($download, $target, $null) + # PowerShell converts plain $null to an empty string for this .NET parameter. + [IO.File]::Replace($download, $target, [System.Management.Automation.Language.NullString]::Value) } else { [IO.File]::Move($download, $target) } From b453039eba7fe451a1e3ec2445916a47885d5081 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:05:06 +0000 Subject: [PATCH 4/4] feat: add Magpie menus and pin all wrapper downloads --- .github/workflows/magpie-prepare.yml | 84 ---------------------------- README.md | 11 ++++ menu.ps1 | 5 +- menu.sh | 9 ++- test-ai-tools.py | 2 +- test.py | 2 +- 6 files changed, 19 insertions(+), 94 deletions(-) delete mode 100644 .github/workflows/magpie-prepare.yml diff --git a/.github/workflows/magpie-prepare.yml b/.github/workflows/magpie-prepare.yml deleted file mode 100644 index 34e542f..0000000 --- a/.github/workflows/magpie-prepare.yml +++ /dev/null @@ -1,84 +0,0 @@ -name: Prepare Magpie menu update -on: - push: - branches: [feat/magpie-installers] -permissions: - contents: read -jobs: - prepare: - if: github.repository == 'TokenNotIncluded/lmm-scripts' - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: write - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - - name: Existing offline contracts before menu changes - run: | - python3 test.py - python3 test-ai-tools.py - - name: Patch menus, existing expectations and README - env: - INSTALLER_REV: ${{ github.sha }} - run: | - python3 - <<'PY' - from pathlib import Path - import os - import re - - revision = os.environ['INSTALLER_REV'] - assert re.fullmatch(r'[0-9a-f]{40}', revision) - - def replace(path, old, new): - file = Path(path) - text = file.read_text() - count = text.count(old) - if count != 1: - raise SystemExit(f'{path}: expected one match, got {count}: {old!r}') - file.write_text(text.replace(old, new)) - - replace('menu.sh', 'cherry-studio ollama uv)', 'cherry-studio ollama uv magpie)') - replace('menu.sh', 'tools=(pi dsh lmm codex claude-code codewhale);', 'tools=(pi dsh lmm codex claude-code codewhale magpie);') - replace('menu.sh', ' revision=873bb8de2d9af00fb2a5fbb592b3da094e665271', ' revision=' + revision) - replace('menu.sh', ' case "$tool" in pi|dsh|lmm|codex|claude-code|cc-switch|clash-verge-rev|codewhale|opencode) ;; *) revision=main;; esac\n', '') - replace('menu.sh', '/$tool.sh") || continue', '/$tool.sh") || { printf \'Could not download %s.\\n\' "$tool" >&2; continue; }') - replace('menu.ps1', "'cherry-studio','ollama','uv')", "'cherry-studio','ollama','uv','magpie')") - replace('menu.ps1', "$revision = '873bb8de2d9af00fb2a5fbb592b3da094e665271'", "$revision = '" + revision + "'") - replace('menu.ps1', " if ($tool -notin @('pi','dsh','lmm','codex','claude-code','cc-switch','clash-verge-rev','codewhale','opencode')) { $revision = 'main' }\n", '') - - # Existing contracts retain their original scope; only the menu expectations change. - if Path('test.py').exists(): - replace('test.py', "result.stdout.splitlines(),['pi','dsh','lmm','codex','claude-code','codewhale']", "result.stdout.splitlines(),['pi','dsh','lmm','codex','claude-code','codewhale','magpie']") - if Path('test-ai-tools.py').exists(): - replace('test-ai-tools.py', 'self.assertEqual(set(entries[9:]),set(ALL))', "self.assertEqual(set(entries[9:]),set(ALL) | {'magpie'})") - if Path('README.md').exists(): - replace('README.md', '## 更多 AI 工具', '''## Magpie - - 菜单第 **24** 项(Termux 第 **7** 项)安装或更新 [Magpie](https://github.com/yetone/magpie),原有编号不变。 - - - Linux / macOS / Termux:`bash magpie.sh`,复用官方安装器;Linux 自动选择桌面或终端版。 - - Windows x64 / ARM64:`.\\magpie.ps1`,安装官方最新正式发行包并校验 SHA-256;`-Cli` 选择终端版。 - - 重复运行同一脚本即可更新。不自动启动、登录、写入 API Key 或更改供应商配置。 - - 单独下载的菜单统一使用同一个固定脚本提交,不再混用旧版本和 `main`;`LMM_SCRIPTS_REV` 仍可显式覆盖。 - 安装目录、上游选项、平台条件和测试说明见 [MAGPIE.md](MAGPIE.md)。 - - ## 更多 AI 工具''') - PY - - name: Offline regression checks - run: | - python3 test.py - python3 test-ai-tools.py - python3 test-magpie.py - shellcheck *.sh - - name: PowerShell offline checks - shell: pwsh - run: ./test-magpie.ps1 - - name: Commit checked menu update - run: | - rm .github/workflows/magpie-prepare.yml - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add menu.sh menu.ps1 README.md test.py test-ai-tools.py .github/workflows/magpie-prepare.yml - git commit -m 'feat: add Magpie menus and pin all wrapper downloads' - git push origin HEAD:feat/magpie-installers diff --git a/README.md b/README.md index fa9d6c2..5b948b1 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,17 @@ irm https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/main/menu.ps1 | `opencode` | 官方 npm 最新 OpenCode + 独立 LMM OAuth 插件 | `opencode auth login --provider lmm`,然后 `opencode` | | `codewhale` | 官方 npm 安装 + 固定版本 LMM OAuth 适配器 | `bash codewhale.sh run` / `.\codewhale.ps1 run` | +## Magpie + +菜单第 **24** 项(Termux 第 **7** 项)安装或更新 [Magpie](https://github.com/yetone/magpie),原有编号不变。 + +- Linux / macOS / Termux:`bash magpie.sh`,复用官方安装器;Linux 自动选择桌面或终端版。 +- Windows x64 / ARM64:`.\magpie.ps1`,安装官方最新正式发行包并校验 SHA-256;`-Cli` 选择终端版。 +- 重复运行同一脚本即可更新。不自动启动、登录、写入 API Key 或更改供应商配置。 + +单独下载的菜单统一使用同一个固定脚本提交,不再混用旧版本和 `main`;`LMM_SCRIPTS_REV` 仍可显式覆盖。 +安装目录、上游选项、平台条件和测试说明见 [MAGPIE.md](MAGPIE.md)。 + ## 更多 AI 工具 新增入口在菜单第 10–23 项,原来的 1–9 项不变。CLI 子菜单可选安装/更新,或配置/登录;配置操作要求工具已经安装并能从 PATH 找到。单个脚本默认执行 `install`,`help` 查看用法。新增脚本完全独立,下载一个文件即可运行,不需要下载共用实现。 diff --git a/menu.ps1 b/menu.ps1 index fe2f1f2..8a8537b 100644 --- a/menu.ps1 +++ b/menu.ps1 @@ -1,6 +1,6 @@ $ErrorActionPreference = 'Stop' if ($args.Count) { throw 'Usage: .\menu.ps1' } -$tools = @('pi','dsh','lmm','codex','claude-code','cc-switch','clash-verge-rev','codewhale','opencode','cursor-cli','grok-build','gemini','qwen-code','kimi','codebuddy','hermes','openclaw','astrbot','aider','cursor','cherry-studio','ollama','uv') +$tools = @('pi','dsh','lmm','codex','claude-code','cc-switch','clash-verge-rev','codewhale','opencode','cursor-cli','grok-build','gemini','qwen-code','kimi','codebuddy','hermes','openclaw','astrbot','aider','cursor','cherry-studio','ollama','uv','magpie') $work = Join-Path ([IO.Path]::GetTempPath()) ("lmm-menu-" + [guid]::NewGuid()) try { New-Item -ItemType Directory $work | Out-Null @@ -34,8 +34,7 @@ try { if ($PSScriptRoot -and (Test-Path (Join-Path $PSScriptRoot $name))) { $path = Join-Path $PSScriptRoot $name } else { - $revision = '873bb8de2d9af00fb2a5fbb592b3da094e665271' - if ($tool -notin @('pi','dsh','lmm','codex','claude-code','cc-switch','clash-verge-rev','codewhale','opencode')) { $revision = 'main' } + $revision = '86391c6e604bdbbf1231770529c47d508eca7d45' if ($env:LMM_SCRIPTS_REV) { $revision = $env:LMM_SCRIPTS_REV } Invoke-WebRequest -UseBasicParsing "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/$revision/$name" -OutFile $path } diff --git a/menu.sh b/menu.sh index 148f2fc..c8eb40b 100755 --- a/menu.sh +++ b/menu.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash set -euo pipefail [[ $# = 0 ]] || { echo 'Usage: bash menu.sh' >&2; exit 2; } -tools=(pi dsh lmm codex claude-code cc-switch clash-verge-rev codewhale opencode cursor-cli grok-build gemini qwen-code kimi codebuddy hermes openclaw astrbot aider cursor cherry-studio ollama uv) -if [[ -n ${TERMUX_VERSION:-} || ${PREFIX:-} == */com.termux/files/usr ]]; then tools=(pi dsh lmm codex claude-code codewhale); fi +tools=(pi dsh lmm codex claude-code cc-switch clash-verge-rev codewhale opencode cursor-cli grok-build gemini qwen-code kimi codebuddy hermes openclaw astrbot aider cursor cherry-studio ollama uv magpie) +if [[ -n ${TERMUX_VERSION:-} || ${PREFIX:-} == */com.termux/files/usr ]]; then tools=(pi dsh lmm codex claude-code codewhale magpie); fi dir=$(dirname -- "${BASH_SOURCE[0]:-}") while true; do printf '\nInstall / update\n' @@ -36,9 +36,8 @@ while true; do if [[ -n ${BASH_SOURCE[0]:-} && -f $dir/$tool.sh ]]; then bash "$dir/$tool.sh" "$@" &2 else - revision=873bb8de2d9af00fb2a5fbb592b3da094e665271 - case "$tool" in pi|dsh|lmm|codex|claude-code|cc-switch|clash-verge-rev|codewhale|opencode) ;; *) revision=main;; esac - script=$(curl -fsSL "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/${LMM_SCRIPTS_REV:-$revision}/$tool.sh") || continue + revision=86391c6e604bdbbf1231770529c47d508eca7d45 + script=$(curl -fsSL "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/${LMM_SCRIPTS_REV:-$revision}/$tool.sh") || { printf 'Could not download %s.\n' "$tool" >&2; continue; } bash -c "$script" -- "$@" &2 fi done diff --git a/test-ai-tools.py b/test-ai-tools.py index 041dd71..8abb14a 100644 --- a/test-ai-tools.py +++ b/test-ai-tools.py @@ -155,7 +155,7 @@ def test_menu_entries_have_both_platforms_and_preserve_original_numbers(self): result=subprocess.run([shutil.which('bash'),'-c',code],env=self.env,text=True,capture_output=True) entries=result.stdout.splitlines() self.assertEqual(entries[:9],['pi','dsh','lmm','codex','claude-code','cc-switch','clash-verge-rev','codewhale','opencode']) - self.assertEqual(set(entries[9:]),set(ALL)) + self.assertEqual(set(entries[9:]),set(ALL) | {'magpie'}) for name in ALL: self.assertTrue((ROOT/(name+'.sh')).is_file()); self.assertTrue((ROOT/(name+'.ps1')).is_file()) self.assertIn("'"+name+"'",(ROOT/'menu.ps1').read_text()) diff --git a/test.py b/test.py index 5a8ef28..6aef97f 100644 --- a/test.py +++ b/test.py @@ -247,7 +247,7 @@ def test_termux_menu_omits_desktop_entries(self): code=(ROOT/'menu.sh').read_text().split('while true; do')[0]+'printf "%s\\n" "${tools[@]}"\n' result=subprocess.run([shutil.which('bash'),'-c',code],env=self.env|{'TERMUX_VERSION':'test'},text=True,capture_output=True,timeout=10) self.assertEqual(result.returncode,0,result.stderr) - self.assertEqual(result.stdout.splitlines(),['pi','dsh','lmm','codex','claude-code','codewhale']) + self.assertEqual(result.stdout.splitlines(),['pi','dsh','lmm','codex','claude-code','codewhale','magpie']) def test_flat_layout_and_size_budget(self): for path in ('templates','tools','docs','versions.json','generate.py'):