From a85c664bbfb9df67045774dcb41ee8df3bac7018 Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sun, 20 Sep 2026 22:18:51 +0530 Subject: [PATCH 1/4] Ship the MSVC runtime in the Windows zip, and clean-room test it (#122) * fix: Stage the MSVC runtime beside wally.exe so the Windows zip is self-contained * ci: Run the shipped Windows zip on a clean PATH to catch missing runtime DLLs * fix: Bundle the OpenSSL DLLs the Windows exe imports so the zip is self-contained * fix: Bundle runtime and OpenSSL DLLs for the product exe only, not the racing test binaries * fix: Harden Windows DLL bundling and clean-room guard per review (arch match, drop x86 fallback, NO_CACHE, empty-runtime warning, assert DLLs in zip) --- .github/workflows/ci.yml | 16 ++++++ .github/workflows/release.yml | 12 ++++ CMakeLists.txt | 1 + cmake/RunAnywhereSDK.cmake | 88 +++++++++++++++++++++++++++--- scripts/test/smoke-zip-windows.ps1 | 45 +++++++++++++++ 5 files changed, 155 insertions(+), 7 deletions(-) create mode 100644 scripts/test/smoke-zip-windows.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96cb9a08..c64a56f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -198,10 +198,15 @@ jobs: kit="${GITHUB_WORKSPACE}/kit" bash scripts/build/fetch-kit.sh windows-arm64 "$kit" test -f "$kit/include/runanywhere/proto/model_types.pb.h" + - name: Resolve product version + shell: bash + run: | + echo "WALLY_VERSION=$(sed -nE 's/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' versions.toml | head -1)" >> "$GITHUB_ENV" - uses: ./.github/actions/build-wally with: platform: windows-arm64 kit-dir: ${{ github.workspace }}/kit + package: 'true' - name: Unit tests run: ctest --test-dir build --output-on-failure -C Release - name: Smoke @@ -209,6 +214,9 @@ jobs: env: WALLY_SDK_KIT: ${{ github.workspace }}/kit run: bash scripts/test/e2e.sh ./build/wally.exe + - name: Clean-room zip smoke + shell: pwsh + run: scripts/test/smoke-zip-windows.ps1 -Zip "dist/wally-${env:WALLY_VERSION}-windows-arm64.zip" windows: runs-on: windows-2022 @@ -239,10 +247,15 @@ jobs: kit="${GITHUB_WORKSPACE}/kit" bash scripts/build/fetch-kit.sh windows-x64 "$kit" test -f "$kit/include/runanywhere/proto/model_types.pb.h" + - name: Resolve product version + shell: bash + run: | + echo "WALLY_VERSION=$(sed -nE 's/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' versions.toml | head -1)" >> "$GITHUB_ENV" - uses: ./.github/actions/build-wally with: platform: windows-x64 kit-dir: ${{ github.workspace }}/kit + package: 'true' - name: Unit tests run: ctest --test-dir build --output-on-failure -C Release - name: Smoke @@ -250,3 +263,6 @@ jobs: env: WALLY_SDK_KIT: ${{ github.workspace }}/kit run: bash scripts/test/e2e.sh ./build/wally.exe + - name: Clean-room zip smoke + shell: pwsh + run: scripts/test/smoke-zip-windows.ps1 -Zip "dist/wally-${env:WALLY_VERSION}-windows-x86_64.zip" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f4162613..66657b85 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -135,6 +135,12 @@ jobs: env: WALLY_SDK_KIT: ${{ github.workspace }}/kit run: bash scripts/test/e2e.sh ./build/wally.exe + - name: Clean-room zip smoke + shell: pwsh + run: | + $ver = "${env:WALLY_VERSION}".TrimStart("v") + $suffix = if ("${{ matrix.variant }}" -eq "dev") { "-dev" } else { "" } + scripts/test/smoke-zip-windows.ps1 -Zip "dist/wally-$ver-windows-arm64$suffix.zip" - name: Verify archive shell: bash run: | @@ -196,6 +202,12 @@ jobs: env: WALLY_SDK_KIT: ${{ github.workspace }}/kit run: bash scripts/test/e2e.sh ./build/wally.exe + - name: Clean-room zip smoke + shell: pwsh + run: | + $ver = "${env:WALLY_VERSION}".TrimStart("v") + $suffix = if ("${{ matrix.variant }}" -eq "dev") { "-dev" } else { "" } + scripts/test/smoke-zip-windows.ps1 -Zip "dist/wally-$ver-windows-x86_64$suffix.zip" - name: Verify archive shell: bash run: | diff --git a/CMakeLists.txt b/CMakeLists.txt index 9f578943..4997a6d7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -288,6 +288,7 @@ elseif(UNIX) INSTALL_RPATH "\$ORIGIN/../lib") endif() wally_stage_windows_runtime_dlls(wally) +wally_bundle_product_dlls(wally) install(TARGETS wally RUNTIME DESTINATION bin) diff --git a/cmake/RunAnywhereSDK.cmake b/cmake/RunAnywhereSDK.cmake index ead3be16..4b020712 100644 --- a/cmake/RunAnywhereSDK.cmake +++ b/cmake/RunAnywhereSDK.cmake @@ -242,13 +242,13 @@ function(wally_define_engine_macros target) # machine where CMake happened to find it anyway. On a clean consumer # it configures fine and then fails at the very end with an undefined # symbol (wally #92). Ask for it here, and say so at configure time. - # Windows is excluded deliberately, not by oversight. Its kit carries the - # same undefined OPENSSL_thread_stop in rac_server.lib, but nothing in - # the Windows build references the object that needs it, so the linker - # never pulls it and the build is green without any OpenSSL at all. - # Requiring it there would break a working build for a dependency that - # does not currently bite. If a Windows link ever fails on that symbol, - # this is the block to extend. + # Windows adds no find_package/link here: the linker resolves that + # OPENSSL_thread_stop against whatever OpenSSL sits on the build machine, + # so the link succeeds without us asking. That is not free -- it leaves + # wally.exe importing libssl-3/libcrypto-3, which must ship beside it or a + # clean machine fails at launch with 0xC0000135 (wally #122). Those two + # DLLs are bundled in wally_bundle_product_dlls below, so the + # Windows archive is self-contained without a link step here. if(NOT WIN32) # Homebrew's openssl@3 is keg-only, so it is not on the default # search path and a bare find_package misses it on an otherwise @@ -316,3 +316,77 @@ function(wally_stage_windows_runtime_dlls target) VERBATIM) endif() endfunction() + +# The MSVC runtime and OpenSSL DLLs only have to ship with the product exe, not +# beside every test binary: the tests run under the build environment's PATH, +# where those DLLs already resolve. Staging them for all ~12 test targets made a +# dozen POST_BUILD commands copy one DLL into build/tests/ at once, which Windows +# fails with a sharing violation (wally #122). Bundle them for the product only. +function(wally_bundle_product_dlls target) + if(NOT WIN32) + return() + endif() + # The exe links the MSVC runtime dynamically (vcruntime140.dll, + # vcruntime140_1.dll on arm64, msvcp140.dll). Those live in the toolchain, so + # a build machine resolves them on PATH but a clean user machine without the + # VC++ redistributable does not -- 0xC0000135 at launch. Stage them so the + # archive carries its own runtime. + if(MSVC) + set(CMAKE_INSTALL_SYSTEM_RUNTIME_LIBS_SKIP TRUE) + include(InstallRequiredSystemLibraries) + if(NOT CMAKE_INSTALL_SYSTEM_RUNTIME_LIBS) + message(WARNING + "wally: InstallRequiredSystemLibraries found no MSVC runtime to " + "bundle; the Windows archive may fail with 0xC0000135 on a clean " + "machine. Check the toolset and arch of the configure environment.") + endif() + foreach(_rt IN LISTS CMAKE_INSTALL_SYSTEM_RUNTIME_LIBS) + get_filename_component(_rt_name "${_rt}" NAME) + add_custom_command(TARGET ${target} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${_rt}" + "$/${_rt_name}" + COMMENT "Stage ${_rt_name} next to $" + VERBATIM) + endforeach() + endif() + # rac_server.lib imports one OpenSSL symbol (OPENSSL_thread_stop), which the + # linker resolves against the build machine's OpenSSL, so the exe ends up + # importing libssl-3/libcrypto-3. httplib is built without TLS here, so this + # is a link-time artefact, not real crypto -- but the DLLs still have to ship + # beside the exe or the archive fails with 0xC0000135 on a machine without + # OpenSSL. Bundle the two the exe actually imports. + if(TARGET RunAnywhere::server) + if(CMAKE_SYSTEM_PROCESSOR MATCHES "ARM64|arm64|aarch64") + set(_wally_ssl_arch "arm64") + else() + set(_wally_ssl_arch "x64") + endif() + foreach(_ossl libssl libcrypto) + # Only the arch-suffixed name can satisfy a 64-bit exe's import table; + # the un-suffixed libssl-3.dll is OpenSSL's 32-bit x86 spelling, so a + # stray one on PATH would stage a wrong-arch DLL. NO_CACHE re-resolves + # every configure, so reusing a build tree across arches cannot pin a + # stale path. + find_file(WALLY_${_ossl}_DLL + NAMES "${_ossl}-3-${_wally_ssl_arch}.dll" + PATHS ENV PATH + PATH_SUFFIXES bin + NO_CACHE) + if(WALLY_${_ossl}_DLL) + get_filename_component(_ossl_name "${WALLY_${_ossl}_DLL}" NAME) + add_custom_command(TARGET ${target} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${WALLY_${_ossl}_DLL}" + "$/${_ossl_name}" + COMMENT "Stage ${_ossl_name} next to $" + VERBATIM) + else() + message(WARNING + "wally: ${_ossl}-3-${_wally_ssl_arch}.dll not found on PATH to " + "bundle; the Windows archive may fail with 0xC0000135 on a " + "clean machine.") + endif() + endforeach() + endif() +endfunction() diff --git a/scripts/test/smoke-zip-windows.ps1 b/scripts/test/smoke-zip-windows.ps1 new file mode 100644 index 00000000..a7fa5013 --- /dev/null +++ b/scripts/test/smoke-zip-windows.ps1 @@ -0,0 +1,45 @@ +param([Parameter(Mandatory = $true)][string]$Zip) +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +# Run the *shipped* zip the way a clean user machine does: extract it to a fresh +# dir and launch wally.exe with only its own bin and the base system on PATH -- +# no toolchain, no kit, no build tree. The build-tree smokes run under the MSVC +# dev environment, where a runtime DLL missing from the archive still resolves +# on PATH and never fails. This is what catches it (0xC0000135 on a clean arm64 +# machine with no VC++ redistributable installed). + +if (-not (Test-Path $Zip)) { throw "archive not found: $Zip" } +$Root = Join-Path ([IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString()) +Expand-Archive -Path $Zip -DestinationPath $Root -Force +$Exe = Get-ChildItem $Root -Filter wally.exe -File -Recurse | Select-Object -First 1 +if (-not $Exe) { throw "wally.exe not found in $Zip" } +$Bin = Split-Path $Exe.FullName -Parent + +# System32 stays on PATH so the base OS resolves, but the MSVC runtime can be +# deployed centrally into System32, so a launch alone would pass even if the zip +# omits it. Assert the bundled DLLs are physically in the archive, so the guard +# proves the archive is self-contained rather than what the runner happens to have. +foreach ($pat in @("vcruntime140.dll", "msvcp140.dll", "libssl-3-*.dll", "libcrypto-3-*.dll")) { + if (-not (Get-ChildItem -Path $Bin -Filter $pat -File -ErrorAction SilentlyContinue)) { + throw "archive is missing '$pat' in bin/ (a user machine would fail even where CI's System32 hides it)" + } +} + +$Saved = $env:PATH +$env:PATH = "$Bin;$env:SystemRoot\System32;$env:SystemRoot" +try { + & $Exe.FullName version + if ($LASTEXITCODE -ne 0) { throw "clean-PATH launch exited $LASTEXITCODE (missing runtime DLL?)" } + & $Exe.FullName about --json | Out-Null + if ($LASTEXITCODE -ne 0) { throw "about --json exited $LASTEXITCODE" } + Write-Host "clean-room launch ok: $($Exe.FullName)" +} catch { + $env:PATH = $Saved # restore before the dump needs its own DLLs + $d = Get-Command dumpbin.exe -ErrorAction SilentlyContinue + if ($d) { & $d.Source /dependents $Exe.FullName } + throw +} finally { + $env:PATH = $Saved + Remove-Item $Root -Recurse -Force -ErrorAction SilentlyContinue +} From 2fd425a8cad7adb1e83950f72d333068f833a551 Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Mon, 21 Sep 2026 21:34:29 +0530 Subject: [PATCH 2/4] Offline LLM-only cut: account/models grammar, nested help, refreshed catalog (#123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: LLM-only command surface with a flat help tree * feat: Filter the built-in model catalog to language models only * feat: LLM-only CLI surface — account/models grammar, nested help with examples, no dev flags * feat: Refresh the on-device catalog to the latest LLMs (LFM2.5, Granite 4.2, SmolLM2) * docs: Trim docs and skills to the LLM-only surface * test: catch up CI to the LLM-only cut and models-only grammar * feat: Collapse model list to one row per model (mlx/llama.cpp), minimize names, gate MLX/ANE to Apple * refactor: Tidy every command's --help — usage lines, aligned options, tighter examples * docs: Refresh MODELS.md for merged catalog rows and mlx-/ane- pull ids * test: Guard MLX/ANE catalog assertions behind __APPLE__ so the non-Apple unit build stays green * review: address all 40 review comments on the offline LLM-only cut Fixes every open review comment on PR #123, plus several stale call sites the review did not reach. Build, ctest (13/13), smoke, cross-shell installer test and check-agents-sync are all green. The ones that could bite a user: - `-u`/`-U` could hijack an unrelated command. `fallthrough(true)` is inherited by every subcommand, so `wally models list -u` climbed back to the root and fired the update/uninstall callback -- replacing the requested command and skipping shutdown(). The flags are no longer registered at all; run() honours them only when the shortcut is the entire command line. - Three live "you are not signed in" messages in the harness still told people to run `wally login`, which now exits 2. Those, docs/EDITORS.md and the model-recovery demo move to `wally account login`. EDITORS.md also pointed at `wally auth login`, which is unregistered too. - The catalog offered models the build cannot run. platform_supports() now gates llama.cpp on WALLY_HAS_LLAMACPP and QHexRT on WALLY_HAS_QHEXRT, matching the macros bootstrap.cpp already guards on, instead of inferring from the OS. - A collapsed `models list` row printed the merge key, which always resolves to the llama.cpp variant -- so a row could read "downloaded: yes" while `models show/rm` acted on something else. A downloaded variant's own id now wins. `local_path`, dropped from --json by the grouping rewrite, is back. - "You typed it wrong..!" was printed for arguments that were merely omitted. CLI11's own message is used instead: `wally models pull` now says "model is required". - Disabled tests reported green while asserting nothing, behind `/* */` blocks that cannot nest. Each file gets one WALLY_LLM_ONLY_CUT switch; preserved bodies sit in #if/#else, disabled tests are unregistered rather than passing, and a half-done revert now fails to compile. Confirmed the reviewer's point: an unknown subcommand and a missing required argument both exit 2, so the diarize/rerank exit-2 tests were passing for the wrong reason. - The cross-shell installer fixture matched on $1, which is now `account`, so it exited 0 and every run skipped the sign-in branches it exists to test. It now dispatches the nested grammar and errors on anything it does not know. Also: REQUIRED marker restored in help, child footers restored under --help-all, dead make_subcommand override removed, serve/run/pull help corrected, account and usage Examples footers restored, configure-time get_subcommand lookups guarded against an unhandled throw at startup, --home and the color/progress flags visible again, and README/AGENTS.md/MODELS.md/ device-e2e skill brought in line with the surface this release ships. Two reviewer claims were stale (the unit tests they said would fail were already fixed) and are documented rather than "fixed". One test change was not requested by anyone: applying the backend gate turned overlay_catalog red, because the engine macros are PRIVATE to wally_core and the test could not see which backends the kit shipped. tests/CMakeLists.txt now passes them to test_wally_unit, and the hiding is pinned in both directions. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WpRbLvoGVTuNgfnQajAf9Z * test: Size overlay_catalog row table explicitly so Windows kits without overlay rows stop hitting MSVC C2466 Co-authored-by: Cursor * test: Gate catalog_lookup size floor and llama.cpp probes on the kit's engine macros Co-authored-by: Cursor * test: Expect an empty LLM catalog on kits with no LLM backend Co-authored-by: Cursor * review: hide non-LLM models from list, fix rag default id, note CORS default, correct test comment * Add CODEOWNERS file for merge approval process * catalog: Cut the ANE rows and refuse --engine ane on kits without NeuRT Co-authored-by: Cursor * help: Group commands by intent, list namespaces as full paths, color on a tty Co-authored-by: Cursor * about: List only engines that serve generate_text while the LLM-only cut holds Co-authored-by: Cursor * backends: restore full backend listing, move llm-only filter off collect_backend_rows Co-authored-by: Cursor * about: Filter the LLM engines in about/info, not in the shared backends collector Co-authored-by: Cursor * harness: Serve MLX locally, size context from RAM, gate coding tools to 20B+ non-1-bit models that fit Co-authored-by: Cursor * harness: Always give dsh a key reference so a local model gets a turn; log the requested model in the shim Co-authored-by: Cursor * catalog: qwen3.8-27b's context window is 262144, not 4096 Co-authored-by: Cursor * harness: Coding tools run on hosted models only; refuse a local model outright Co-authored-by: Cursor --------- Co-authored-by: Sanchit Monga Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Cursor --- .agents/skills/wally-device-e2e/SKILL.md | 66 ++- .agents/skills/wally-e2e/SKILL.md | 8 - .claude/skills/wally-device-e2e/SKILL.md | 66 ++- .claude/skills/wally-e2e/SKILL.md | 8 - .github/CODEOWNERS | 3 + AGENTS.md | 5 +- README.md | 23 +- docs/EDITORS.md | 13 +- docs/ENGINES.md | 13 +- docs/MODELS.md | 76 +--- install.ps1 | 4 +- install.sh | 10 +- scripts/test/e2e-linux.sh | 48 +- scripts/test/model-recovery-demo.sh | 2 +- scripts/test/smoke-mlx.sh | 50 ++- scripts/test/test-install-cross-shell.sh | 21 +- skills/runanywhere/SKILL.md | 20 +- src/account/console.cpp | 2 +- src/anthropic/messages.cpp | 13 +- src/app.cpp | 309 +++++++------ src/bootstrap.cpp | 2 +- src/catalog/catalog.cpp | 541 +++++++++++++---------- src/catalog/catalog.h | 12 + src/catalog/model_ref.cpp | 2 +- src/cli_formatter.cpp | 355 +++++++++------ src/cli_formatter.h | 51 ++- src/commands/cmd_about.cpp | 4 +- src/commands/cmd_account.cpp | 44 +- src/commands/cmd_backends.cpp | 10 + src/commands/cmd_bench.cpp | 4 +- src/commands/cmd_default_models.cpp | 26 +- src/commands/cmd_editors.cpp | 18 +- src/commands/cmd_embed.cpp | 6 +- src/commands/cmd_harness.cpp | 26 +- src/commands/cmd_info.cpp | 31 +- src/commands/cmd_list.cpp | 173 ++++++-- src/commands/cmd_maintenance.cpp | 20 +- src/commands/cmd_models.cpp | 42 +- src/commands/cmd_pull.cpp | 11 +- src/commands/cmd_rag.cpp | 2 +- src/commands/cmd_rm.cpp | 6 +- src/commands/cmd_run.cpp | 102 +++-- src/commands/cmd_serve.cpp | 20 +- src/commands/cmd_show.cpp | 4 +- src/commands/cmd_tool.cpp | 18 +- src/commands/cmd_update.cpp | 55 +-- src/commands/cmd_usage.cpp | 35 +- src/commands/cmd_version.cpp | 2 +- src/commands/commands.h | 15 + src/commands/engine_options.cpp | 25 ++ src/commands/engine_options.h | 5 + src/commands/model_labels.h | 19 + src/commands/model_setup.h | 3 +- src/harness/agents.cpp | 55 ++- src/harness/catalog_models.cpp | 5 +- src/harness/harness.cpp | 102 +++-- src/harness/harness.h | 3 +- src/harness/local_models.cpp | 40 ++ src/harness/local_models.h | 9 + tests/CMakeLists.txt | 7 + tests/test_account_cli.py | 19 +- tests/test_wally_harness.cpp | 48 +- tests/test_wally_mlx_e2e.cpp | 52 ++- tests/test_wally_segment.cpp | 16 + tests/test_wally_unit.cpp | 399 ++++++++++++++--- 65 files changed, 2126 insertions(+), 1078 deletions(-) create mode 100644 .github/CODEOWNERS diff --git a/.agents/skills/wally-device-e2e/SKILL.md b/.agents/skills/wally-device-e2e/SKILL.md index 134b6643..7c1b0276 100644 --- a/.agents/skills/wally-device-e2e/SKILL.md +++ b/.agents/skills/wally-device-e2e/SKILL.md @@ -1,15 +1,19 @@ --- name: wally-device-e2e -description: Run engine-agnostic wally modality e2e on Apple Neural Engine (NeuRT) and Snapdragon Hexagon NPU (QHexRT) devices. Use when adding overlay backends, proving LLM/STT/TTS/VLM/embed/diffusion on device, or when a PC only has one modality's bundles on disk. +description: Run wally's LLM e2e on Apple Neural Engine (NeuRT) and Snapdragon Hexagon NPU (QHexRT) devices. Use when adding overlay backends, proving LLM inference on device, or when a PC only has one backend's bundles on disk. Non-LLM modalities (STT/TTS/VLM/embed/image/VAD/rerank/segment/diarize) are deferred while the LLM-only cut is in effect. --- # Wally device modality e2e Do not write per-engine tests. The harness is `scripts/test/e2e-modalities.sh`, -called from `scripts/test/e2e.sh`. Keys are **primitives** (`llm`, `stt`, `tts`, -`vlm`, `embed`, `image`, `vad`, `rerank`, `segment`). wally picks the engine -from catalog framework, local path, or plugin priority. `--engine` is an -override (`WALLY_E2E_ENGINE`), never a required test input. +called from `scripts/test/e2e.sh`, keyed by primitive (`llm`, `stt`, `tts`, +`vlm`, `embed`, `image`, `vad`, `rerank`, `segment`, `diarize`). This build's +LLM-only cut (`src/app.cpp`) registers only the `llm` command — every other +primitive's wally subcommand is commented out, so pointing the harness at one +now fails with "no such command", not a skip. Run `llm` only until that cut is +lifted. wally picks the engine from catalog framework, local path, or plugin +priority. `--engine` is an override (`WALLY_E2E_ENGINE`), never a required +test input. ## Run @@ -17,39 +21,34 @@ override (`WALLY_E2E_ENGINE`), never a required test input. # Public CI (modelless): skip every modality bash scripts/test/e2e.sh /path/to/wally -# Device: discover whatever is already on disk, then run each primitive +# Device: discover whatever is already on disk, then run llm export RUNANYWHERE_HOME=/path/to/home # already-pulled OSS models export WALLY_E2E_MODEL_ROOTS=/path/to/hnpu:/path/to/coreml bash scripts/test/e2e-modalities.sh /path/to/wally -# Or pin one primitive (path or catalog id) +# Or pin the model explicitly (path or catalog id) WALLY_E2E_LLM=/path/to/lfm2_5_230m_HNPU \ -WALLY_E2E_STT=/path/to/whisper_base_HNPU \ -WALLY_E2E_TTS=/path/to/kitten_micro_0_8_HNPU \ -WALLY_E2E_EMBED=/path/to/embeddinggemma_300m_HNPU \ bash scripts/test/e2e-modalities.sh /path/to/wally ``` -`WALLY_E2E_AUTO=1` pulls small OSS catalog defaults the **registered** backends -can run (`smollm2`, `whisper-tiny`, `piper`, `minilm`, `silero`, `mlx-qwen3`, -…). Never enable AUTO in public CI. +`WALLY_E2E_AUTO=1` also sets defaults for `stt`/`tts`/`vlm`/`embed`/`vad`/ +`rerank`/`segment`/`image` (`whisper-tiny`, `piper`, `minilm`, …); on this +LLM-only cut every one of those now fails with "no such command" instead of +skipping, since their wally subcommand does not exist. Only the `llm` default +(`smollm2` / `mlx-qwen3`) actually runs — treat any other AUTO failure as the +disabled command, not your change. Never enable AUTO in public CI. -## Why a device "only has LLM" +## Local model ids -QHexRT and NeuRT implement STT/TTS/VLM/embed/diffusion. The Windows ARM64 box -often only has LFM `*_HNPU` trees under `Downloads\hnpu` because those were -copied for LLM smoke — not because the engine is LLM-only. Catalog ids: +The Windows ARM64 box often only has LFM `*_HNPU` trees under `Downloads\hnpu`, +copied for LLM smoke. Catalog ids: -| Modality | QHexRT id (local `*_HNPU`) | NeuRT id (local Core ML tree) | -|---|---|---| -| LLM | `lfm2_5_230m` | `lfm2_5_230m_ane` | -| STT | `whisper_base`, `moonshine_tiny` | `parakeet_tdt_0_6b_v2_ane` | -| TTS | `kitten_micro_0_8` | — | -| Embed | `embeddinggemma_300m` | — | -| VLM | `internvl3_5_1b` (~10 GB) | — | -| Image | `cosmos3_edge_diffusion` | `sd15` | +| QHexRT id (local `*_HNPU`) | NeuRT id (local Core ML tree) | +|---|---| +| `lfm2_5_230m` | `lfm2_5_230m_ane` | -`wally pull` of a Hugging Face **repo page** is HTML. Pass the expanded + +`wally models pull` of a Hugging Face **repo page** is HTML. Pass the expanded directory to `-m`. Download `v81/*` only on Hexagon v81. Skip with a clear "no bundle" when the tree is missing. Fail only when a @@ -64,15 +63,10 @@ model was selected and the command failed. `...\lib\hexagon-v81\unsigned` path. Nested `%QNN_SDK_ROOT%` in `cmd /c set` does not expand. Copy `QnnHtp*.dll` next to `wally.exe`. FastRPC ~90s then user-driver fallback is normal. Use a `.bat`, not nested `cmd /c`. -- **NeuRT image:** `--prompt` and `--out` required; `--steps 4` for smoke. - Compiled zip, not the HF repo HTML. Tree needs `TextEncoder.mlmodelc` / - `Unet.mlmodelc` / `VAEDecoder.mlmodelc`. -- **llama.cpp VLM:** do **not** add a literal `` in the prompt — the - SDK inserts `mtmd_default_marker()`. An extra `` makes - `mtmd_tokenize` see 0 media markers. A tiny PNG can `bad_alloc` in - SmolVLM2 after the 512×512 warmup; skip or pass a real photo via - `WALLY_E2E_VLM`. -- **segment:** binary P6 PPM, not PNG. -- STT has no `--engine` flag; put `-m` before the wav. + +Non-LLM overlay coverage (NeuRT image generation, llama.cpp VLM, segment, STT) +is deferred, not deleted: those primitives run through this same harness once +`src/app.cpp`'s LLM-only cut is uncommented, but until then their wally +subcommands do not exist, so this skill does not instruct running them. See `wally-e2e` for bottle/backends assertions and Apple MLX host link flags. diff --git a/.agents/skills/wally-e2e/SKILL.md b/.agents/skills/wally-e2e/SKILL.md index 7e9a819d..b594938d 100644 --- a/.agents/skills/wally-e2e/SKILL.md +++ b/.agents/skills/wally-e2e/SKILL.md @@ -181,14 +181,6 @@ Mac; ARM64 MSVC + QHexRT overlay on Snapdragon). `arm64-windows-static` into the kit `lib/` before linking (fixed in the SDK packager for the *next* kit; do not retag 0.20.28). Wally already links kit `libcurl.lib` when present. -- **`wally image generate` needs `--prompt` and `--out`**, not a positional - prompt. `--steps 4` is enough for a smoke PNG. Help exists on the public - bottle; real generate is compiled only with `WALLY_HAS_NEURT`. -- **`sd15` catalog URL must be the compiled zip**, not the HF repo page - (HTML ~160 KB). Unzip to a tree with `TextEncoder.mlmodelc` / - `Unet.mlmodelc` / `VAEDecoder.mlmodelc` and pass that directory. COREML - / QHEXRT catalog rows register `ModelInfo` (folder), not the single-file - download factory — `wally pull sd15` is not a substitute for the zip. - Published product bottles: macOS `wally-$V-macos-arm64.tar.gz`, Windows **x64** zip. There is no public Windows ARM64 bottle; NPU is overlay-only. - **The private QHexRT overlay tarball used to ship zero skel files** (only diff --git a/.claude/skills/wally-device-e2e/SKILL.md b/.claude/skills/wally-device-e2e/SKILL.md index 134b6643..7c1b0276 100644 --- a/.claude/skills/wally-device-e2e/SKILL.md +++ b/.claude/skills/wally-device-e2e/SKILL.md @@ -1,15 +1,19 @@ --- name: wally-device-e2e -description: Run engine-agnostic wally modality e2e on Apple Neural Engine (NeuRT) and Snapdragon Hexagon NPU (QHexRT) devices. Use when adding overlay backends, proving LLM/STT/TTS/VLM/embed/diffusion on device, or when a PC only has one modality's bundles on disk. +description: Run wally's LLM e2e on Apple Neural Engine (NeuRT) and Snapdragon Hexagon NPU (QHexRT) devices. Use when adding overlay backends, proving LLM inference on device, or when a PC only has one backend's bundles on disk. Non-LLM modalities (STT/TTS/VLM/embed/image/VAD/rerank/segment/diarize) are deferred while the LLM-only cut is in effect. --- # Wally device modality e2e Do not write per-engine tests. The harness is `scripts/test/e2e-modalities.sh`, -called from `scripts/test/e2e.sh`. Keys are **primitives** (`llm`, `stt`, `tts`, -`vlm`, `embed`, `image`, `vad`, `rerank`, `segment`). wally picks the engine -from catalog framework, local path, or plugin priority. `--engine` is an -override (`WALLY_E2E_ENGINE`), never a required test input. +called from `scripts/test/e2e.sh`, keyed by primitive (`llm`, `stt`, `tts`, +`vlm`, `embed`, `image`, `vad`, `rerank`, `segment`, `diarize`). This build's +LLM-only cut (`src/app.cpp`) registers only the `llm` command — every other +primitive's wally subcommand is commented out, so pointing the harness at one +now fails with "no such command", not a skip. Run `llm` only until that cut is +lifted. wally picks the engine from catalog framework, local path, or plugin +priority. `--engine` is an override (`WALLY_E2E_ENGINE`), never a required +test input. ## Run @@ -17,39 +21,34 @@ override (`WALLY_E2E_ENGINE`), never a required test input. # Public CI (modelless): skip every modality bash scripts/test/e2e.sh /path/to/wally -# Device: discover whatever is already on disk, then run each primitive +# Device: discover whatever is already on disk, then run llm export RUNANYWHERE_HOME=/path/to/home # already-pulled OSS models export WALLY_E2E_MODEL_ROOTS=/path/to/hnpu:/path/to/coreml bash scripts/test/e2e-modalities.sh /path/to/wally -# Or pin one primitive (path or catalog id) +# Or pin the model explicitly (path or catalog id) WALLY_E2E_LLM=/path/to/lfm2_5_230m_HNPU \ -WALLY_E2E_STT=/path/to/whisper_base_HNPU \ -WALLY_E2E_TTS=/path/to/kitten_micro_0_8_HNPU \ -WALLY_E2E_EMBED=/path/to/embeddinggemma_300m_HNPU \ bash scripts/test/e2e-modalities.sh /path/to/wally ``` -`WALLY_E2E_AUTO=1` pulls small OSS catalog defaults the **registered** backends -can run (`smollm2`, `whisper-tiny`, `piper`, `minilm`, `silero`, `mlx-qwen3`, -…). Never enable AUTO in public CI. +`WALLY_E2E_AUTO=1` also sets defaults for `stt`/`tts`/`vlm`/`embed`/`vad`/ +`rerank`/`segment`/`image` (`whisper-tiny`, `piper`, `minilm`, …); on this +LLM-only cut every one of those now fails with "no such command" instead of +skipping, since their wally subcommand does not exist. Only the `llm` default +(`smollm2` / `mlx-qwen3`) actually runs — treat any other AUTO failure as the +disabled command, not your change. Never enable AUTO in public CI. -## Why a device "only has LLM" +## Local model ids -QHexRT and NeuRT implement STT/TTS/VLM/embed/diffusion. The Windows ARM64 box -often only has LFM `*_HNPU` trees under `Downloads\hnpu` because those were -copied for LLM smoke — not because the engine is LLM-only. Catalog ids: +The Windows ARM64 box often only has LFM `*_HNPU` trees under `Downloads\hnpu`, +copied for LLM smoke. Catalog ids: -| Modality | QHexRT id (local `*_HNPU`) | NeuRT id (local Core ML tree) | -|---|---|---| -| LLM | `lfm2_5_230m` | `lfm2_5_230m_ane` | -| STT | `whisper_base`, `moonshine_tiny` | `parakeet_tdt_0_6b_v2_ane` | -| TTS | `kitten_micro_0_8` | — | -| Embed | `embeddinggemma_300m` | — | -| VLM | `internvl3_5_1b` (~10 GB) | — | -| Image | `cosmos3_edge_diffusion` | `sd15` | +| QHexRT id (local `*_HNPU`) | NeuRT id (local Core ML tree) | +|---|---| +| `lfm2_5_230m` | `lfm2_5_230m_ane` | -`wally pull` of a Hugging Face **repo page** is HTML. Pass the expanded + +`wally models pull` of a Hugging Face **repo page** is HTML. Pass the expanded directory to `-m`. Download `v81/*` only on Hexagon v81. Skip with a clear "no bundle" when the tree is missing. Fail only when a @@ -64,15 +63,10 @@ model was selected and the command failed. `...\lib\hexagon-v81\unsigned` path. Nested `%QNN_SDK_ROOT%` in `cmd /c set` does not expand. Copy `QnnHtp*.dll` next to `wally.exe`. FastRPC ~90s then user-driver fallback is normal. Use a `.bat`, not nested `cmd /c`. -- **NeuRT image:** `--prompt` and `--out` required; `--steps 4` for smoke. - Compiled zip, not the HF repo HTML. Tree needs `TextEncoder.mlmodelc` / - `Unet.mlmodelc` / `VAEDecoder.mlmodelc`. -- **llama.cpp VLM:** do **not** add a literal `` in the prompt — the - SDK inserts `mtmd_default_marker()`. An extra `` makes - `mtmd_tokenize` see 0 media markers. A tiny PNG can `bad_alloc` in - SmolVLM2 after the 512×512 warmup; skip or pass a real photo via - `WALLY_E2E_VLM`. -- **segment:** binary P6 PPM, not PNG. -- STT has no `--engine` flag; put `-m` before the wav. + +Non-LLM overlay coverage (NeuRT image generation, llama.cpp VLM, segment, STT) +is deferred, not deleted: those primitives run through this same harness once +`src/app.cpp`'s LLM-only cut is uncommented, but until then their wally +subcommands do not exist, so this skill does not instruct running them. See `wally-e2e` for bottle/backends assertions and Apple MLX host link flags. diff --git a/.claude/skills/wally-e2e/SKILL.md b/.claude/skills/wally-e2e/SKILL.md index 7e9a819d..b594938d 100644 --- a/.claude/skills/wally-e2e/SKILL.md +++ b/.claude/skills/wally-e2e/SKILL.md @@ -181,14 +181,6 @@ Mac; ARM64 MSVC + QHexRT overlay on Snapdragon). `arm64-windows-static` into the kit `lib/` before linking (fixed in the SDK packager for the *next* kit; do not retag 0.20.28). Wally already links kit `libcurl.lib` when present. -- **`wally image generate` needs `--prompt` and `--out`**, not a positional - prompt. `--steps 4` is enough for a smoke PNG. Help exists on the public - bottle; real generate is compiled only with `WALLY_HAS_NEURT`. -- **`sd15` catalog URL must be the compiled zip**, not the HF repo page - (HTML ~160 KB). Unzip to a tree with `TextEncoder.mlmodelc` / - `Unet.mlmodelc` / `VAEDecoder.mlmodelc` and pass that directory. COREML - / QHEXRT catalog rows register `ModelInfo` (folder), not the single-file - download factory — `wally pull sd15` is not a substitute for the zip. - Published product bottles: macOS `wally-$V-macos-arm64.tar.gz`, Windows **x64** zip. There is no public Windows ARM64 bottle; NPU is overlay-only. - **The private QHexRT overlay tarball used to ship zero skel files** (only diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..ba0f65ea --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +# Require review from an approved merger before anything can merge. +# See https://docs.github.com/articles/about-codeowners +* @RunanywhereAI/merge-approvers diff --git a/AGENTS.md b/AGENTS.md index b12ed677..265e75df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,8 +96,9 @@ built with namespace isolation. Never `find_package(Protobuf)` against Homebrew. ## Command surface -Dual grammar: spec namespaces (`llm generate`, `models download`) plus terminal -aliases (`run`, `pull`, `stt`). One `configure_*` wires both. See +Dual grammar: spec namespaces (`llm generate`, `models download`) plus the +terminal alias `run`. Model verbs (`list`/`pull`/`rm`/`show`/`default`) live +under `models` only — no top-level shortcut. One `configure_*` wires both. See `src/commands/commands.h`. Do not reintroduce FetchContent of the SDK, a second inference backend tree, diff --git a/README.md b/README.md index f4e70c19..fcc5410b 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ **Run open models on your own machine, or hosted when the job outgrows it.** -Chat, vision, speech and embeddings from one terminal command. Local models -never leave your device. Hosted ones run on RunAnywhere Cloud and are billed -against your own credit. +Chat with a language model from one terminal command. Local models never leave +your device. Hosted ones run on RunAnywhere Cloud and are billed against your +own credit.
@@ -29,7 +29,7 @@ irm https://raw.githubusercontent.com/RunanywhereAI/wally/main/install.ps1 | iex You don't need an account or a key, and nothing leaves the machine. ```bash -wally pull qwen3 # download +wally models pull qwen3 # download wally run qwen3 # chat wally run qwen3 "Hello" # one answer and exit wally serve qwen3 # OpenAI-compatible API on :8080 (macOS, Linux) @@ -39,7 +39,7 @@ wally serve qwen3 # OpenAI-compatible API on :8080 (macOS, Linux) works too: ```bash -wally pull hf.co/Qwen/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf +wally models pull hf.co/Qwen/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf ``` ## Use a hosted model in your coding agent @@ -48,10 +48,10 @@ Sign in once. The terminal shows a code and you approve it in the browser, so you never paste a key. ```bash -wally login +wally account login wally opencode --cloud -m glm-5.3-flash wally claude-code -m glm-5.3-flash -wally usage # credit left and recent spend +wally account usage # credit left and recent spend ``` Hosted models today are `glm-5.3-flash`, `qwen3.8-27b` and `gemma-4`. The same @@ -63,15 +63,14 @@ commands work with a model on your machine, and `claude-desktop`, `hermes` and | | | |---|---| | `wally run` | chat, or one answer with a prompt | -| `wally pull` / `wally rm` | download or delete a model | -| `wally list` | models on this machine | +| `wally models pull` / `wally models rm` | download or delete a model | +| `wally models list` | models on this machine | | `wally serve` | OpenAI-compatible API | -| `wally login` / `wally usage` | sign in, check credit | +| `wally account login` / `wally account usage` | sign in, check credit | | `wally opencode` / `wally claude-code` | start a coding agent on a model | | `wally update` | update wally to the latest release | -`wally --help` and `wally --help` cover the rest, including vision, -speech, embeddings and image generation. +`wally --help` and `wally --help` cover the rest. ## Build from source diff --git a/docs/EDITORS.md b/docs/EDITORS.md index a58d346b..629e7d1b 100644 --- a/docs/EDITORS.md +++ b/docs/EDITORS.md @@ -94,13 +94,13 @@ enough. A model you have not downloaded can still answer, if the console serves it: ```bash -wally login -wally whoami +wally account login +wally account whoami wally run gemma-4-31b-it "why is the sky blue" ``` -`wally login` opens the console in a browser and waits for you to approve the -machine. `wally logout` deletes the session. +`wally account login` opens the console in a browser and waits for you to +approve the machine. `wally account logout` deletes the session. Where the credential is kept depends on the platform, and `WALLY_PROFILE_DIR` moves it anywhere: @@ -114,6 +114,7 @@ moves it anywhere: `WALLY_CONSOLE_WEB_URL` at the page that approves the sign-in. Those are two different hosts; see [AGENTS.md](../AGENTS.md). -This is separate from `wally auth login`, which signs a device in with an API -key rather than a browser. Most people want `wally login`. +`wally auth login`, which signed a device in with an API key rather than a +browser, is not registered in this release; the browser flow above is the only +way in. diff --git a/docs/ENGINES.md b/docs/ENGINES.md index 75d82df8..24cc3421 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -11,10 +11,9 @@ Override only when you mean it: ```bash wally llm generate --engine mlx -m mlx-qwen3 "Hello" wally run --engine qhexrt /path/to/lfm2_5_230m_HNPU "Hello" -wally image generate --engine neurt --prompt "a red cube" --out out.png ``` -`--engine` accepts `mlx`, `llamacpp`, `sherpa`, `onnx`, `neurt` / `coreml` / `ane`, and `qhexrt` / `qnn` / `npu` / `hexagon`. If you omit it, commons picks the highest-priority **registered** backend that implements that primitive: +`--engine` accepts `mlx`, `llamacpp`, `sherpa`, `onnx`, `qhexrt` / `qnn` / `npu` / `hexagon`, and, only in a build that linked the NeuRT overlay, `neurt` / `coreml` / `ane`. A build without NeuRT refuses those three with "not in this build", and each command's `--help` lists only the engines that binary has. If you omit it, commons picks the highest-priority **registered** backend that implements that primitive: | Priority | Engine | Who wins unpinned work | |---|---|---| @@ -47,15 +46,7 @@ Yes = this engine implements the primitive. Try = a catalog id that `wally pull` | Modality | Command | llama.cpp | MLX | Sherpa | ONNX | NeuRT | QHexRT | |---|---|---|---|---|---|---|---| | LLM | `wally run` / `llm generate` | yes · `smollm2`, `qwen3` | yes · `mlx-qwen3` | — | — | yes · `lfm2-230m-ane` local Core ML tree | yes · `lfm2-230m-npu` local `*_HNPU` | -| VLM | `wally vlm generate --image` | yes · `smolvlm2` | yes · `mlx-qwen2-vl` | — | — | — | yes · `internvl-1b-npu` local HNPU | -| TTS | `wally tts synthesize -o out.wav` | — | yes · `mlx-soprano` | yes · `piper` | — | — | yes · `kitten-micro-npu` local HNPU | -| STT | `wally stt transcribe audio.wav` | — | yes · `mlx-qwen3-asr` | yes · `whisper-tiny` | — | yes · `parakeet-tdt-v2-ane` local Core ML | yes · `whisper-base-npu` local HNPU | -| VAD | `wally vad detect audio.wav` | — | — | yes | yes · `silero` | — | — | -| Embeddings | `wally embed` | yes · `nemotron-3-embed` | yes · `mlx-qwen3-embed` | — | yes · `minilm` | — | yes · `embeddinggemma-npu` local HNPU | -| Rerank | `wally rerank -d …` | yes · `bge-reranker` | — | — | — | — | yes · `nv-rerank-npu` local HNPU | -| Segmentation | `wally segment image.ppm` (binary P6 PPM) | — | — | — | yes · `segformer` | — | — | -| Diarization | `wally diarize audio.wav` | — | — | — | yes · `sortformer` | — | — | -| Image gen | `wally image generate --prompt … --out …` | — | — | — | — | yes · `sd15` (compiled Core ML zip, not the HF repo HTML) | yes · `cosmos3-diffusion-npu` local HNPU | + MLX registers with a one-line `-811` then Swift callbacks install it — that warning is expected. `image generate` is compiled only when NeuRT is linked; `--prompt` and `--out` are required (not a positional prompt). `--steps 4` is enough for a smoke PNG. diff --git a/docs/MODELS.md b/docs/MODELS.md index 57e4db1c..6e921983 100644 --- a/docs/MODELS.md +++ b/docs/MODELS.md @@ -2,64 +2,32 @@ `wally models list --all` is the live list. -Catalog models are grouped by the org that trains them. GGUF rows run on llama.cpp (macOS, Windows x64, Linux). `mlx-*` rows run on Apple Silicon only. +This table groups models by publisher; the id you pull depends on the engine +you want: + +- `wally models pull ` — the llama.cpp / GGUF build (macOS, Windows x64, Linux). +- `wally models pull mlx-` — the MLX build (Apple Silicon only). + +MLX builds exist on Apple Silicon only; on any other platform they are hidden +from the catalog. The short aliases (`qwen3`, `llama3.2`, `smollm2`, …) still +resolve. + +There are no Apple Neural Engine (`ane-`) rows in this release. The NeuRT +engine that runs them is a private overlay, not in the public kit, and the +published Core ML repos hold directory trees rather than a downloadable +archive. The rows are commented out in `src/catalog/catalog.cpp` under +`TEMP(ane-cut)` and come back together with real artifacts. ### Language -| Org | Families | Try | +| Org | Families | Pull | |---|---|---| -| [Alibaba Qwen](https://huggingface.co/Qwen) | Qwen3, Qwen3.6, Qwen3.8 | `qwen3`, `mlx-qwen3` | -| [Meta](https://huggingface.co/meta-llama) | Llama 3.2 | `llama3.2`, `mlx-llama3.2` | -| [Google](https://huggingface.co/google) | Gemma 4 | `gemma4-e2b`, `mlx-gemma4-e2b` | -| [Hugging Face](https://huggingface.co/HuggingFaceTB) | SmolLM2 | `smollm2` | -| [Liquid AI](https://huggingface.co/LiquidAI) | LFM2 | `lfm2` | -| [IBM](https://huggingface.co/ibm-granite) | Granite 4.1 | `granite4.1-3b`, `mlx-granite4.1-3b` | +| [Alibaba Qwen](https://huggingface.co/Qwen) | Qwen3, Qwen3.8 | `qwen3-0.6b`, `mlx-qwen3-0.6b` | +| [Meta](https://huggingface.co/meta-llama) | Llama 3.2 | `llama-3.2-3b`, `mlx-llama3.2` | +| [Google](https://huggingface.co/google) | Gemma 4 | `gemma-4-e2b`, `mlx-gemma-4-e2b` | +| [Hugging Face](https://huggingface.co/HuggingFaceTB) | SmolLM2 | `smollm2-135m` | +| [Liquid AI](https://huggingface.co/LiquidAI) | LFM2.5 | `lfm2.5-230m`, `lfm2.5-350m`, `lfm2.5-1.2b`, `lfm2.5-2.6b` | +| [IBM](https://huggingface.co/ibm-granite) | Granite 4.1, Granite 4.2 | `granite-4.1-3b`, `granite-4.2-8b` | | [NVIDIA](https://huggingface.co/nvidia) | Nemotron | `mlx-nemotron-nano` | | [PrismML](https://huggingface.co/prism-ml) | Bonsai, Ternary-Bonsai | `bonsai-1.7b`, `mlx-bonsai-1.7b` | | [DeepGrove](https://huggingface.co/deepgrove) | Maple Preview | `maple-preview`, `mlx-maple-preview` | - -### Vision - -| Org | Families | Try | -|---|---|---| -| Hugging Face | SmolVLM2 | `smolvlm2` | -| Alibaba Qwen | Qwen2-VL | `qwen2-vl`, `mlx-qwen2-vl` | -| Liquid AI | LFM2-VL, LFM2.5-VL | `lfm2-vl`, `mlx-lfm2.5-vl` | -| Apple | FastVLM | `mlx-fastvlm` | -| Microsoft | Fara 1.5 (computer use) | `fara` | -| Meta | Muse Glimmer | `muse-glimmer` | -| NVIDIA | Nemotron Omni | `nemotron-omni` | - -```bash -wally vlm generate --model smolvlm2 --image photo.png "What is in this picture?" -``` - -### Speech - -| Org | Families | Role | Try | -|---|---|---|---| -| OpenAI | Whisper | STT | `whisper-tiny` | -| NVIDIA | Parakeet, Canary, Nemotron ASR | STT | `parakeet-tdt-v2` | -| Alibaba Qwen | Qwen3-ASR / Qwen3-TTS | STT / TTS (MLX) | `mlx-qwen3-asr` | -| [rhasspy](https://github.com/rhasspy/piper) | Piper | TTS | `piper` | -| [Supertone](https://huggingface.co/Supertone) | Supertonic | TTS | `supertonic` | -| [Zhipu](https://huggingface.co/THUDM) | GLM-ASR | STT (MLX) | `mlx-glm-asr` | -| [Silero](https://github.com/snakers4/silero-vad) | Silero | VAD | `silero` | - -```bash -wally tts synthesize "Hello from the device." -o hello.wav -wally stt transcribe hello.wav -``` - -### Embeddings, rerank, other - -| Org | Families | Role | Try | -|---|---|---|---| -| NVIDIA | Nemotron Embed, Llama-Nemotron Embed | embeddings | `nemotron-3-embed` | -| Alibaba Qwen | Qwen3 Embedding | embeddings (MLX) | `mlx-qwen3-embed` | -| [sentence-transformers](https://huggingface.co/sentence-transformers) | MiniLM | embeddings | `minilm` | -| [BAAI](https://huggingface.co/BAAI) | BGE Reranker | rerank | `bge-reranker` | -| NVIDIA | Sortformer | diarization | `sortformer` | -| [NVIDIA / Hugging Face](https://huggingface.co/nvidia) | SegFormer | segmentation | `segformer` | -| Stability AI / Apple | Stable Diffusion 1.5 | image gen (NeuRT) | `sd15` | - diff --git a/install.ps1 b/install.ps1 index 8539704d..49e72396 100644 --- a/install.ps1 +++ b/install.ps1 @@ -163,8 +163,8 @@ Write-Host '' Write-Warn 'Open a new terminal before running wally. This one was started with the old PATH.' Write-Host '' Write-Info 'Getting started:' -Write-Host ' wally list --all every model in the catalog' -Write-Host ' wally pull qwen3-0.6b download one' +Write-Host ' wally models list --all every model in the catalog' +Write-Host ' wally models pull qwen3-0.6b download one' Write-Host ' wally run qwen3-0.6b talk to it, /? for commands' Write-Host ' wally backends which engines this build linked' Write-Host '' diff --git a/install.sh b/install.sh index a50ba48a..2d5048c5 100755 --- a/install.sh +++ b/install.sh @@ -237,15 +237,15 @@ IFS="$old_ifs" # being left as an instruction the person has to notice. Already signed in is a # no-op, and a failure is not fatal: the CLI is installed either way. step "Signing in" -if wally whoami >/dev/null 2>&1; then +if wally account whoami >/dev/null 2>&1; then ok "already signed in" elif [ ! -t 0 ] || [ ! -t 1 ]; then # No terminal: piped into bash over SSH, or a CI step. The browser flow # would try to open a browser that is not there and then block until the # request expires, which reads as the installer hanging. - warn "not an interactive terminal — run \`wally login\` yourself" + warn "not an interactive terminal — run \`wally account login\` yourself" else - wally login || warn "sign-in did not finish. Run \`wally login\` when you are ready." + wally account login || warn "sign-in did not finish. Run \`wally account login\` when you are ready." fi # --- summary ---------------------------------------------------------------- @@ -258,6 +258,6 @@ printf ' %s└───────────────────── printf ' %sNext:%s\n' "$B" "$R" printf ' wally opencode --cloud -m glm-5.3-flash code against a hosted model\n' -printf ' wally usage credit left and what you spent\n' -printf ' wally pull qwen3-0.6b download a model to this machine\n' +printf ' wally account usage credit left and what you spent\n' +printf ' wally models pull qwen3-0.6b download a model to this machine\n' printf ' In Claude Code, ask: %s"get me started with RunAnywhere Wally"%s\n\n' "$DIM" "$R" diff --git a/scripts/test/e2e-linux.sh b/scripts/test/e2e-linux.sh index c9136c93..3213670f 100755 --- a/scripts/test/e2e-linux.sh +++ b/scripts/test/e2e-linux.sh @@ -41,7 +41,9 @@ wally() { "$BIN" --home "$HOME_DIR" "$@"; } pass=0 fail=0 +skip=0 failed_names=() +skipped_names=() check() { local name="$1" local log="$LOG_DIR/${name}.log" @@ -56,6 +58,21 @@ check() { fi } +# TEMP(llm-only cut): the non-LLM modality commands (tts/stt/vad/voice) are +# commented out of src/app.cpp and not registered for this release, so their +# checks below cannot pass or meaningfully fail -- they would just error out +# on "no such command". Report them as skipped instead of running them, so a +# green summary here is never mistaken for coverage of that surface. Switch +# the call sites back to `check` when register_vlm/register_stt/register_tts/ +# register_voice are uncommented in src/app.cpp. +skip_case() { + local name="$1" + local reason="$2" + echo " ${name}... SKIP ($reason)" + skip=$((skip + 1)) + skipped_names+=("$name") +} + smoke_version() { wally version | grep -E 'wally|[0-9]+\.[0-9]+'; } smoke_backends() { @@ -66,7 +83,7 @@ smoke_backends() { echo "$out" | grep -qiE "sherpa|onnx" } -smoke_list_all() { wally list --all; } +smoke_list_all() { wally models list --all; } smoke_info_json() { wally --json info | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d.get("wally") or d.get("version")' @@ -92,10 +109,10 @@ hermetic_pull_rm() { curl -sf http://127.0.0.1:8077/ >/dev/null 2>&1 && break sleep 1 done - wally --no-progress pull http://127.0.0.1:8077/silero_vad.onnx - wally list | grep -q silero_vad - wally rm silero_vad --force - ! wally list | grep -q silero_vad + wally --no-progress models pull http://127.0.0.1:8077/silero_vad.onnx + wally models list | grep -q silero_vad + wally models rm silero_vad --force + ! wally models list | grep -q silero_vad pkill -f "http.server 8077" || true rm -rf "$stage" } @@ -114,6 +131,10 @@ llm_one_shot() { test -n "$out" } +# TEMP(llm-only cut): tts/stt are commented out of src/app.cpp for this +# release. Kept here, unreachable via `check`, so this comes back verbatim +# once register_tts/register_stt are uncommented -- nothing else has to +# change. tts_stt_roundtrip() { wally --no-progress pull piper || wally --no-progress pull piper-en wally tts --text "RunAnywhere runs models on device." --output /tmp/wally-e2e-tts.wav @@ -125,12 +146,18 @@ tts_stt_roundtrip() { echo "$transcript" | grep -iE "run|anywhere|models|device" } +# TEMP(llm-only cut): vad is commented out of src/app.cpp for this release. +# Kept here, unreachable via `check`, so this comes back verbatim once +# register_vad is uncommented -- nothing else has to change. vad_segments() { wally --no-progress pull piper || wally --no-progress pull piper-en wally tts --text "Testing voice activity detection." --output /tmp/wally-e2e-vad.wav wally --json vad --input /tmp/wally-e2e-vad.wav | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d.get("segments") or d.get("speech") or isinstance(d, (dict, list))' } +# TEMP(llm-only cut): voice is commented out of src/app.cpp for this release. +# Kept here, unreachable via `check`, so this comes back verbatim once +# register_voice is uncommented -- nothing else has to change. voice_turn() { wally --no-progress pull piper || wally --no-progress pull piper-en wally --no-progress pull whisper-tiny @@ -192,14 +219,17 @@ else echo echo "==> Real inference (canonical-layout models)" check llm_one_shot - check tts_stt_roundtrip - check vad_segments - check voice_turn + skip_case tts_stt_roundtrip "tts/stt disabled for llm-only cut" + skip_case vad_segments "vad disabled for llm-only cut" + skip_case voice_turn "voice disabled for llm-only cut" check serve_health fi echo -echo "Summary: $pass passed, $fail failed" +echo "Summary: $pass passed, $fail failed, $skip skipped" +if [[ "$skip" -gt 0 ]]; then + echo "Skipped: ${skipped_names[*]}" +fi if [[ "$fail" -gt 0 ]]; then echo "Failed: ${failed_names[*]}" echo "Logs: $LOG_DIR" diff --git a/scripts/test/model-recovery-demo.sh b/scripts/test/model-recovery-demo.sh index c77a097a..990bdad0 100755 --- a/scripts/test/model-recovery-demo.sh +++ b/scripts/test/model-recovery-demo.sh @@ -5,7 +5,7 @@ # harness a coding agent you have installed (default: opencode) # model the model to launch (default: glm-5.3-flash) # -# Requires: you are signed in (`wally login`) and the chosen harness is installed. +# Requires: you are signed in (`wally account login`) and the chosen harness is installed. # # It seeds a *stale* catalog cache that is populated but missing the model, then # launches the harness. Because the model is missing, wally runs the recovery: diff --git a/scripts/test/smoke-mlx.sh b/scripts/test/smoke-mlx.sh index 2ee23c18..23d290e0 100755 --- a/scripts/test/smoke-mlx.sh +++ b/scripts/test/smoke-mlx.sh @@ -36,7 +36,7 @@ wally() { "$BIN" --home "$HOME_DIR" "$@"; } pull_if_enabled() { local model="$1" if [[ "$PULL" == "1" ]]; then - wally pull "$model" + wally models pull "$model" fi } @@ -74,28 +74,38 @@ llm_out="$(wally run "$LLM_MODEL" "Say OK in one short sentence." --max-tokens 1 require_text "LLM" "$llm_out" printf '%s\n' "$llm_out" +# TEMP(llm-only cut): TTS, STT, and VLM are commented out of src/app.cpp for +# this release, and their catalog ids (mlx-soprano-*, mlx-qwen3-asr-*, +# mlx-fastvlm-*) are filtered out of the language-only catalog, so both +# `wally models pull` and the command itself would fail here -- and abort +# the whole script under `set -euo pipefail`. Skip and report the skip +# instead of running them. Restore these three sections unguarded when +# register_tts/register_stt/register_vlm are uncommented in src/app.cpp. echo "TTS: $TTS_MODEL" -pull_if_enabled "$TTS_MODEL" -tts_wav="$HOME_DIR/mlx-smoke-tts.wav" -rm -f "$tts_wav" -wally tts --model "$TTS_MODEL" --text "Hello from MLX text to speech." --output "$tts_wav" -require_file "$tts_wav" +echo "TTS: SKIP (tts disabled for llm-only cut)" +# pull_if_enabled "$TTS_MODEL" +# tts_wav="$HOME_DIR/mlx-smoke-tts.wav" +# rm -f "$tts_wav" +# wally tts --model "$TTS_MODEL" --text "Hello from MLX text to speech." --output "$tts_wav" +# require_file "$tts_wav" echo "STT: $STT_MODEL" -pull_if_enabled "$STT_MODEL" -stt_out="$(wally stt --model "$STT_MODEL" --input "$tts_wav")" -require_text "STT" "$stt_out" -printf '%s\n' "$stt_out" +echo "STT: SKIP (stt disabled for llm-only cut)" +# pull_if_enabled "$STT_MODEL" +# stt_out="$(wally stt --model "$STT_MODEL" --input "$tts_wav")" +# require_text "STT" "$stt_out" +# printf '%s\n' "$stt_out" echo "VLM: $VLM_MODEL" -pull_if_enabled "$VLM_MODEL" -image_path="${WALLY_SMOKE_IMAGE:-${RUNANYWHERE_MLX_SMOKE_IMAGE:-$HOME_DIR/mlx-smoke-image.png}}" -if [[ -z "${WALLY_SMOKE_IMAGE:-${RUNANYWHERE_MLX_SMOKE_IMAGE:-}}" ]]; then - make_default_image "$image_path" -fi -vlm_out="$(wally run "$VLM_MODEL" --image "$image_path" \ - "Describe the image in one short sentence." --max-tokens 32 --temp 0.1)" -require_text "VLM" "$vlm_out" -printf '%s\n' "$vlm_out" +echo "VLM: SKIP (vlm disabled for llm-only cut)" +# pull_if_enabled "$VLM_MODEL" +# image_path="${WALLY_SMOKE_IMAGE:-${RUNANYWHERE_MLX_SMOKE_IMAGE:-$HOME_DIR/mlx-smoke-image.png}}" +# if [[ -z "${WALLY_SMOKE_IMAGE:-${RUNANYWHERE_MLX_SMOKE_IMAGE:-}}" ]]; then +# make_default_image "$image_path" +# fi +# vlm_out="$(wally run "$VLM_MODEL" --image "$image_path" \ +# "Describe the image in one short sentence." --max-tokens 32 --temp 0.1)" +# require_text "VLM" "$vlm_out" +# printf '%s\n' "$vlm_out" -echo "smoke-mlx: ok" +echo "smoke-mlx: ok (tts/stt/vlm skipped for llm-only cut)" diff --git a/scripts/test/test-install-cross-shell.sh b/scripts/test/test-install-cross-shell.sh index 57070411..0f28842b 100755 --- a/scripts/test/test-install-cross-shell.sh +++ b/scripts/test/test-install-cross-shell.sh @@ -56,15 +56,26 @@ UNAME chmod +x "$STUB/uname" # A good fixture: a release tarball whose bin/wally answers --version, -# whoami and login the way the real binary does, plus a matching sha256. +# `account whoami` and `account login` the way the real binary does, plus a +# matching sha256. +# +# The nesting matters. `whoami`/`login` moved under `account`, so a fake that +# still matched on $1 alone saw `account`, fell off the end of the case, and +# exited 0 -- which install.sh reads as "already signed in", so every run +# skipped the sign-in branches this fixture exists to exercise. Dispatching on +# the real two-token grammar puts them back under test. The `*)` arm is the +# other half: an invocation this stub does not know is a hard error, the way +# the real CLI exits 2 on an unknown command, so if install.sh ever drifts back +# to the flat spelling the test fails loudly instead of silently passing. GOOD="$WORK/fixture-good" mkdir -p "$GOOD/wally-macos-arm64/bin" cat > "$GOOD/wally-macos-arm64/bin/wally" <<'WALLY' #!/bin/sh -case "$1" in - --version) echo "wally 1.2.3 (stub)" ;; - whoami) exit 1 ;; - login) echo "stub login ok" ;; +case "$1 $2" in + "--version ") echo "wally 1.2.3 (stub)" ;; + "account whoami") exit 1 ;; + "account login") echo "stub login ok" ;; + *) echo "stub wally: unexpected invocation: $*" >&2; exit 2 ;; esac WALLY chmod +x "$GOOD/wally-macos-arm64/bin/wally" diff --git a/skills/runanywhere/SKILL.md b/skills/runanywhere/SKILL.md index 7ed2d3ee..2a9c1621 100644 --- a/skills/runanywhere/SKILL.md +++ b/skills/runanywhere/SKILL.md @@ -27,22 +27,22 @@ in on its own. ## First check what is already true ```bash -wally whoami # signed in? which console? +wally account whoami # signed in? which console? wally backends # which engines this build linked ``` -`wally whoami` failing with "not signed in" is the only thing that needs fixing +`wally account whoami` failing with "not signed in" is the only thing that needs fixing before anything else works against the cloud. On-device models need no account. ## Signing in ```bash -wally login +wally account login ``` Opens the console in a browser. The person signs in with Google or GitHub, approves the terminal, and the CLI stores a key in `~/.config/wally`. There is no -password and no organization step. If a browser cannot open, `wally login +password and no organization step. If a browser cannot open, `wally account login --no-browser` prints the URL to visit. ## Coding harnesses @@ -66,8 +66,8 @@ opencode harness works?" is a better second message than a launched TUI. ## Running a model directly ```bash -wally pull qwen3-0.6b # download it -wally list # what is downloaded +wally models pull qwen3-0.6b # download it +wally models list # what is downloaded wally run qwen3-0.6b # talk to it ``` @@ -76,16 +76,16 @@ Models land in `~/.local/share/runanywhere`. Nothing is downloaded until asked. ## Spend ```bash -wally usage # credit left, then input/output/cache tokens and spend -wally usage --json +wally account usage # credit left, then input/output/cache tokens and spend +wally account usage --json ``` Read-only, and scoped to the signed-in account. ## When something is wrong -- **"not signed in"** — `wally login`. -- **"that key is not valid"** — the key was revoked or expired; `wally login` again. +- **"not signed in"** — `wally account login`. +- **"that key is not valid"** — the key was revoked or expired; `wally account login` again. - **opencode not installed** — `npm i -g opencode-ai`. - **a model is slow or unavailable** — `wally backends` shows which engines this build actually linked; a model needing an engine that is not there will not run. diff --git a/src/account/console.cpp b/src/account/console.cpp index ad783ea7..0a4ce4f5 100644 --- a/src/account/console.cpp +++ b/src/account/console.cpp @@ -481,7 +481,7 @@ void HttpError(const char* operation, const std::string& origin, const HttpRespo *error = wait >= 0 ? "Wally Cloud is busy - try again in " + std::to_string(wait) + "s" : "Wally Cloud is busy - try again in a moment"; } else if (status == 401 || status == 403) { - *error = "your cloud session is no longer valid - run `wally login`"; + *error = "your cloud session is no longer valid - run `wally account login`"; } else if (status == 404) { *error = "Wally Cloud has no such endpoint"; } else if (status >= 500) { diff --git a/src/anthropic/messages.cpp b/src/anthropic/messages.cpp index 24309a61..7e6bbae2 100644 --- a/src/anthropic/messages.cpp +++ b/src/anthropic/messages.cpp @@ -645,10 +645,6 @@ bool Start(const harness::Endpoint& upstream, const std::string& model, Shim* sh "application/json"); return; } - if (raw->verbose) { - out::status_line("anthropic: POST /v1/messages, " + - std::to_string(request.body.size()) + " bytes"); - } Json parsed; try { parsed = Json::parse(request.body); @@ -658,6 +654,15 @@ bool Start(const harness::Endpoint& upstream, const std::string& model, Shim* sh "application/json"); return; } + if (raw->verbose) { + // The id the app asked for and the one that will answer, side by + // side: this is the line that shows a picker choice being honoured + // or silently collapsing onto the launched default. + const std::string requested = parsed.value("model", std::string("")); + out::status_line("anthropic: POST /v1/messages, " + + std::to_string(request.body.size()) + " bytes, model " + requested + + " -> " + EffectiveModel(*raw, parsed)); + } try { if (parsed.value("stream", false)) { HandleStreaming(*raw, request, parsed, response); diff --git a/src/app.cpp b/src/app.cpp index 231f4ea4..299c79ab 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -1,6 +1,7 @@ #include "app.h" #include +#include #include #include #include @@ -25,142 +26,164 @@ namespace wally { void configure_app(CLI::App& app, GlobalOptions& options) { - app.set_version_flag("--version,-V", std::string("wally ") + WALLY_VERSION); + // Set before any subcommand registers: a subcommand copies its parent's + // help flag at construction. + app.set_help_flag("-h,--help", "Show help"); + app.set_version_flag("--version,-V", std::string("wally ") + WALLY_VERSION, + "Show the wally version"); app.require_subcommand(0, 1); app.fallthrough(true); - app.add_flag("--json", options.json, "Machine-readable JSON output on stdout"); + app.add_flag("--json", options.json, "Print results as JSON"); app.add_flag("-v,--verbose", options.verbose, "Debug logging on stderr"); - app.add_flag("-q,--quiet", options.quiet, "Errors only on stderr"); + app.add_flag("-q,--quiet", options.quiet, "Errors only"); + // Visible in --help, unlike the control-plane flags below: all three are + // things a person running models day to day reaches for -- piping output + // into a script or log (--no-progress, --no-color) or keeping models on + // another disk (--home) -- not a developer-only knob for a non-default + // backend. app.add_flag("--no-progress", options.no_progress, "Disable progress rendering"); app.add_flag("--no-color", options.no_color, "Disable colored --help output"); app.add_option("--home", options.home_override, - "RunAnywhere home directory (default: $RUNANYWHERE_HOME or " - "~/.local/share/runanywhere; models live under /Models)"); - - // Control-plane connection. validation happens in resolve_connection(). - // Developer/SDK-facing, not something a person reaches for day to day -- - // group("") drops them out of the default --help listing the same way - // `telemetry` is hidden below, while leaving them fully parseable - // (flags and RUNANYWHERE_* env fallbacks both still resolve). - app.add_option("--environment", options.environment, - "SDK environment: development (default, keyless OSS → baked staging " - "backend) or production (API key + https URL).") - ->envname("RUNANYWHERE_ENVIRONMENT") - ->check(CLI::IsMember({"dev", "development", "prod", "production"})) - ->group(""); - app.add_option("--base-url", options.base_url, - "Backend base URL. Optional in development (baked staging URL). " - "Required https for production.") - ->envname("RUNANYWHERE_BASE_URL") - ->group(""); - app.add_option("--api-key", options.api_key, - "Control-plane API key (required for production; omit for " - "keyless development)") - ->envname("RUNANYWHERE_API_KEY") - ->group(""); - - // Namespaces first (the spec grammar), then the terminal aliases, then the - // infrastructure commands — that is the order `--help` lists them in. - commands::register_llm(app, options); - commands::register_vlm(app, options); - commands::register_tool(app, options); // must follow register_llm (extends the `llm` group) - commands::register_stt(app, options); - commands::register_tts(app, options); - commands::register_vad(app, options); - commands::register_embed(app, options); - commands::register_rerank(app, options); - commands::register_image(app, options); - commands::register_diarize(app, options); - commands::register_segment(app, options); - commands::register_voice(app, options); - commands::register_rag(app, options); + "Where models live (default $RUNANYWHERE_HOME or ~/.local/share/runanywhere)"); + + // `-u`/`-U` (aliases for `update`/`uninstall`) are deliberately NOT + // registered as CLI11 flags here. `app.fallthrough(true)` above is + // inherited by every subcommand at construction, so a flag by these names + // anywhere in the tree is reachable from inside any subcommand's own + // argument list -- `wally models list -u` would climb straight back up to + // this app and fire the shortcut instead of erroring on an unknown + // `models list` option, quietly running an update in place of the + // requested command. `add_flag_callback` made this worse by calling + // std::exit() mid-parse, which also skips run()'s shutdown(). The + // shortcuts are still honoured, but only when `-u`/`-U`/`--update`/ + // `--uninstall` is the entire command line -- see the argv check in + // run(), which reaches the normal shutdown() path. The documented + // spelling is still `wally update` / `wally uninstall`. + + // Control-plane connection (environment / base URL / API key) is not exposed + // as CLI flags: resolve_connection() reads RUNANYWHERE_ENVIRONMENT / + // RUNANYWHERE_BASE_URL / RUNANYWHERE_API_KEY straight from the environment + // (bootstrap.cpp), so a dev build still overrides via env with no + // developer-only flags cluttering the surface. + + // Registration order is the --help print order: run first (the primary + // verb), then llm and models, serve, the coding agents, the cloud account, + // then diagnostics and maintenance. bench/backends/telemetry are registered + // but hidden from the list further down. + commands::register_llm_aliases(app, options); // `run` + commands::register_llm(app, options); // `llm` (must precede register_tool) + commands::register_tool(app, options); // attaches to `llm` + // TEMP(llm-only cut): every non-LLM modality is hidden from --help and from + // execution for this release. Re-enable the full surface by uncommenting + // this block as a whole -- nothing else has to change. + // commands::register_vlm(app, options); + // commands::register_stt(app, options); + // commands::register_tts(app, options); + // commands::register_vad(app, options); + // commands::register_embed(app, options); + // commands::register_rerank(app, options); + // commands::register_image(app, options); + // commands::register_diarize(app, options); + // commands::register_segment(app, options); + // commands::register_voice(app, options); + // commands::register_rag(app, options); + // commands::register_lora(app, options); commands::register_models(app, options); - commands::register_lora(app, options); + commands::register_serve(app, options); - commands::register_llm_aliases(app, options); - commands::register_models_aliases(app, options); + commands::register_editors(app, options); + commands::register_harness(app, options); // coding agents + commands::register_default_models(app, options); + + commands::register_account(app, options); // account login/logout/whoami/usage + commands::register_usage(app, options); // attaches `usage` under `account` + // `auth` (device sign-in against the control plane) is a developer path that + // duplicates `account login`; unregister it. Uncomment to restore. + // commands::register_auth(app, options); - commands::register_serve(app, options); - commands::register_bench(app, options); - commands::register_backends(app, options); - commands::register_help(app, options); - commands::register_uninstall(app, options); commands::register_info(app, options); commands::register_about(app, options); commands::register_version(app, options); commands::register_update(app, options); - commands::register_auth(app, options); - commands::register_account(app, options); - commands::register_usage(app, options); - commands::register_editors(app, options); - commands::register_harness(app, options); - commands::register_default_models(app, options); - commands::register_telemetry(app, options); - - // `--help` groups: CLI11 prints one heading per distinct group string, in - // the order each group is first seen (Formatter::make_subcommands), so - // this order is the print order. Centralized here rather than one - // ->group() call per register_* file: 36 top-level commands with no - // grouping at all used to land in a single default SUBCOMMANDS: bucket. - // Grouped so the split a reader cares about is visible at a glance: what - // runs on this machine, versus what talks to the hosted console. The - // coding agents sit between the two because they do both — a local model or - // a hosted one behind the same command — so they carry the "(local or - // hosted)" tag rather than landing in either camp. - constexpr const char* kGenerate = "Generate (on-device)"; - constexpr const char* kModels = "On-device models"; - constexpr const char* kAgents = "Coding agents (local or hosted)"; - constexpr const char* kCloud = "Cloud account"; - constexpr const char* kServe = "Serve & benchmark (on-device)"; - constexpr const char* kAbout = "About"; - const std::vector> help_groups = { - {"llm", kGenerate}, {"vlm", kGenerate}, {"stt", kGenerate}, - {"tts", kGenerate}, {"vad", kGenerate}, {"embed", kGenerate}, - {"rerank", kGenerate}, {"image", kGenerate}, {"diarize", kGenerate}, - {"segment", kGenerate}, {"voice", kGenerate}, {"rag", kGenerate}, - {"run", kModels}, {"chat", kModels}, {"ls", kModels}, - {"show", kModels}, {"pull", kModels}, {"rm", kModels}, - {"models", kModels}, {"lora", kModels}, - {"opencode", kAgents}, {"claude-code", kAgents}, - {"claude-desktop", kAgents}, - {"hermes", kAgents}, {"openclaw", kAgents}, - {"deepseek", kAgents}, - {"default-models", kAgents}, - {"auth", kCloud}, {"login", kCloud}, {"logout", kCloud}, - {"whoami", kCloud}, {"usage", kCloud}, - {"serve", kServe}, {"bench", kServe}, {"backends", kServe}, - {"info", kAbout}, {"about", kAbout}, {"version", kAbout}, - {"update", kAbout}, + commands::register_uninstall(app, options); + commands::register_help(app, options); + + commands::register_bench(app, options); // hidden below + commands::register_backends(app, options); // hidden below + commands::register_telemetry(app, options); // hidden below + + // Grouped help, by what the reader is trying to do. Sections print in the + // order their first command was registered above; commands inside a + // section keep registration order too. A namespace (models, account) is + // listed as its children with full paths ("models pull"), which the + // formatter does when a command has visible children. + // + // Everything unnamed here is hidden from the page but still parses: + // `llm generate|stream|tool-call` (the explicit forms behind `run`), + // `info` (a terser `about`), `version` (-V), `help` (-h), and the + // diagnostics. Set by name with a guard, so a rename can never leave a + // stale string to crash the CLI (0xC0000409). + struct Section { + const char* group; + std::vector names; }; - // configure_app() runs ahead of run()'s own try/catch (and tests call it - // directly with none at all), so a typo here must never propagate as an - // uncaught exception -- that crashed the Windows CI binaries outright - // (0xC0000409, no diagnostic) the one time a name here didn't match. - // Report it and keep going with the default flat listing rather than - // taking the whole CLI down over a --help cosmetic. - for (const auto& [name, group] : help_groups) { - try { - app.get_subcommand(name)->group(group); - } catch (const CLI::OptionNotFound&) { - out::error_line(std::string("internal: --help grouping named an unknown " - "subcommand '") + - name + "', skipping it"); - } + const Section sections[] = { + {"Chat", {"run", "serve"}}, + {"Models", {"models"}}, + {"Coding tools", {"opencode", "claude-code", "claude-desktop", "hermes", "openclaw", "deepseek"}}, + {"Account", {"account"}}, + {"Wally", {"about", "update", "uninstall"}}, + }; + for (CLI::App* sub : app.get_subcommands({})) { + if (!sub->get_name().empty()) sub->group(""); } - // Internal debug tool, not a command a user reaches for. An empty group - // string drops a subcommand out of the default listing entirely - // (Formatter::make_subcommands) while it stays fully callable — - // `wally telemetry --help` still works. - try { - app.get_subcommand("telemetry")->group(""); - } catch (const CLI::OptionNotFound&) { - // Nothing to hide if it isn't there. + for (const Section& section : sections) { + for (const char* name : section.names) { + try { + app.get_subcommand(name)->group(section.group); + } catch (const CLI::OptionNotFound&) { + // Not registered in this build; nothing to place. + } + } } } namespace { +/// Friendly reply to a parse error on a (sub)command: walk to the deepest +/// command that actually parsed and print CLI11's own message for what went +/// wrong, followed by that command's help, instead of stopping at the terse +/// top-level usage line. CLI11 already tells the two cases apart correctly in +/// e.what() -- "model is required" / "A subcommand is required" for something +/// left out, "The following argument was not expected: -x" for something +/// misspelled or extra -- so this only needs to walk down to where parsing +/// actually got to; it must not attach its own "you typed it wrong" framing, +/// which would misdescribe an omitted required argument as a typo. Handles +/// both RequiredError and ExtrasError (their common base), detected by +/// exception TYPE at the call site -- ExtrasError::get_name() is the app name, +/// not "ExtrasError", so a string match on e.what() would miss cases. +void PrintParseErrorHelp(const CLI::App& app, const CLI::ParseError& e) { + const CLI::App* ctx = &app; + // The names above `ctx`, so its usage line reads `wally models ...` the + // way `wally models --help` prints it. + std::string parents; + for (;;) { + const CLI::App* next = nullptr; + for (const CLI::App* sub : ctx->get_subcommands({})) { + if (sub->parsed()) { + next = sub; + break; + } + } + if (next == nullptr) break; + parents = parents.empty() ? ctx->get_name() : parents + " " + ctx->get_name(); + ctx = next; + } + out::error_line(e.what()); + std::fputs(ctx->help(parents).c_str(), stderr); +} + /// The subcommands that hand the terminal to another tool and forward the rest /// of the command line to it. Kept in step with register_editors and /// register_harness; a name here that is not a real subcommand is harmless. @@ -272,6 +295,26 @@ int run(int argc, char** argv) { GlobalOptions options; RestoreStaleDesktopGateway(argc, argv); + // `-u`/`-U` top-level shortcuts, handled here instead of as CLI11 flags + // (see the comment in configure_app() for why) so they can be guarded to + // the one shape that can't be confused with a subcommand's own arguments: + // the entire command line is the shortcut and nothing else. Goes through + // the same shutdown() every other exit from run() does, unlike the old + // std::exit()-in-a-callback version. + if (argc == 2) { + const std::string only_arg = argv[1]; + if (only_arg == "-u" || only_arg == "--update") { + const int code = commands::run_update(false); + shutdown(); + return code; + } + if (only_arg == "-U" || only_arg == "--uninstall") { + const int code = commands::run_uninstall(false); + shutdown(); + return code; + } + } + // Decided ahead of CLI11's own parse: a subcommand inherits its parent's // formatter_ at construction time (App::App), which configure_app() // triggers below, so the color decision has to already be settled before @@ -284,18 +327,22 @@ int run(int argc, char** argv) { } } - CLI::App app{"RunAnywhere on-device AI CLI — llm, vlm, stt, tts, vad, embed, rerank, " - "image, rag, voice and the models that back them"}; + // Named "wally" outright rather than from argv[0], so the usage line reads + // the same whether the binary was run through the install wrapper, by full + // path, or as wally-cxx. + CLI::App app{"Run models on this machine or on your RunAnywhere account", "wally"}; + // Two colors and nothing else, on a terminal only: bold section headings, + // cyan for anything you can type. Piped or redirected output, --no-color + // and NO_COLOR all get the identical plain text. app.formatter(std::make_shared(color_output_enabled(no_color_requested))); configure_app(app, options); - // Every subcommand here loads a model on this machine; a hosted console - // model (glm-5.3-flash, ...) has no path through `run`/`llm generate` at - // all, and that dead end used to be the only place someone learned the - // cloud path exists. - app.footer( - "A model your account has on the hosted console (not this machine) runs through " - "`wally claude-code -m ` or `wally opencode --cloud -m `, not " - "`run`/`llm generate`."); + // `run` only loads models on this machine; a hosted model (glm-5.3-flash, + // ...) is reached through a coding tool. One line for each path, so a + // first-time reader sees both exist and can paste either. + app.footer("Get started:\n" + " wally models pull qwen3-0.6b && wally run qwen3-0.6b\n" + " wally account login && wally opencode --cloud -m glm-5.3-flash\n" + "\nRun \"wally --help\" for details."); // A `--` before the wrapped tool's own arguments, added for the reader, so // `wally claude-code --dangerously-skip-permissions` forwards the flag @@ -310,7 +357,9 @@ int run(int argc, char** argv) { if (raw.size() >= 2 && raw[1] == "help") { if (raw.size() >= 3 && !raw[2].empty() && raw[2][0] != '-') { try { - std::fputs(app.get_subcommand(raw[2])->help().c_str(), stdout); + // The parent's name, so the usage line reads `wally opencode` + // exactly as `wally opencode --help` prints it. + std::fputs(app.get_subcommand(raw[2])->help(app.get_name()).c_str(), stdout); shutdown(); return 0; } catch (const CLI::Error&) { @@ -341,8 +390,14 @@ int run(int argc, char** argv) { exit_code = app.exit(e); } catch (const CLI::RuntimeError& e) { exit_code = (e.get_exit_code() != 0) ? e.get_exit_code() : 1; + } catch (const CLI::RequiredError& e) { + PrintParseErrorHelp(app, e); // a required (sub)command or option was left out + exit_code = 2; + } catch (const CLI::ExtrasError& e) { + PrintParseErrorHelp(app, e); // an unexpected or misspelled (sub)command or argument + exit_code = 2; } catch (const CLI::ParseError& e) { - app.exit(e); // prints the usage message to stderr + app.exit(e); // any other parse error: keep CLI11's own message exit_code = 2; } catch (const std::exception& e) { out::error_line(e.what()); diff --git a/src/bootstrap.cpp b/src/bootstrap.cpp index 7cce4b99..2c1e65f5 100644 --- a/src/bootstrap.cpp +++ b/src/bootstrap.cpp @@ -575,7 +575,7 @@ rac_result_t bootstrap(const GlobalOptions &options, Bootstrapped *out) { if (!std::filesystem::exists(home, exists_ec)) { out::status_line("warning: --home '" + home + "' does not exist yet; it will be created empty " - "(pass the right path, or `wally pull` into this one)"); + "(pass the right path, or `wally models pull` into this one)"); } } diff --git a/src/catalog/catalog.cpp b/src/catalog/catalog.cpp index 53d12ce5..aaa140c9 100644 --- a/src/catalog/catalog.cpp +++ b/src/catalog/catalog.cpp @@ -1045,71 +1045,6 @@ constexpr CatalogFile kMlxGemma4_31BFiles[] = { "tokenizer_config.json", true}, }; -// mlx-community/Qwen3.6-35B-A3B-4bit (MoE) — config.json model_type -// "qwen3_5_moe", registered in mlx-swift-lm 3.31.5's LLMTypeRegistry. -constexpr CatalogFile kMlxQwen3_6_35BA3BFiles[] = { - {"https://huggingface.co/mlx-community/Qwen3.6-35B-A3B-4bit/resolve/" - "38740b847e4cb78f352aba30aa41c76e08e6eb46/" - "chat_template.jinja", - "chat_template.jinja", true}, - {"https://huggingface.co/mlx-community/Qwen3.6-35B-A3B-4bit/resolve/" - "38740b847e4cb78f352aba30aa41c76e08e6eb46/" - "config.json", - "config.json", true}, - {"https://huggingface.co/mlx-community/Qwen3.6-35B-A3B-4bit/resolve/" - "38740b847e4cb78f352aba30aa41c76e08e6eb46/" - "configuration.json", - "configuration.json", true}, - {"https://huggingface.co/mlx-community/Qwen3.6-35B-A3B-4bit/resolve/" - "38740b847e4cb78f352aba30aa41c76e08e6eb46/" - "generation_config.json", - "generation_config.json", true}, - {"https://huggingface.co/mlx-community/Qwen3.6-35B-A3B-4bit/resolve/" - "38740b847e4cb78f352aba30aa41c76e08e6eb46/" - "model-00001-of-00004.safetensors", - "model-00001-of-00004.safetensors", true, 5288196018LL}, - {"https://huggingface.co/mlx-community/Qwen3.6-35B-A3B-4bit/resolve/" - "38740b847e4cb78f352aba30aa41c76e08e6eb46/" - "model-00002-of-00004.safetensors", - "model-00002-of-00004.safetensors", true, 5368472749LL}, - {"https://huggingface.co/mlx-community/Qwen3.6-35B-A3B-4bit/resolve/" - "38740b847e4cb78f352aba30aa41c76e08e6eb46/" - "model-00003-of-00004.safetensors", - "model-00003-of-00004.safetensors", true, 5368324139LL}, - {"https://huggingface.co/mlx-community/Qwen3.6-35B-A3B-4bit/resolve/" - "38740b847e4cb78f352aba30aa41c76e08e6eb46/" - "model-00004-of-00004.safetensors", - "model-00004-of-00004.safetensors", true, 4377211365LL}, - {"https://huggingface.co/mlx-community/Qwen3.6-35B-A3B-4bit/resolve/" - "38740b847e4cb78f352aba30aa41c76e08e6eb46/" - "model.safetensors.index.json", - "model.safetensors.index.json", true}, - {"https://huggingface.co/mlx-community/Qwen3.6-35B-A3B-4bit/resolve/" - "38740b847e4cb78f352aba30aa41c76e08e6eb46/" - "preprocessor_config.json", - "preprocessor_config.json", true}, - {"https://huggingface.co/mlx-community/Qwen3.6-35B-A3B-4bit/resolve/" - "38740b847e4cb78f352aba30aa41c76e08e6eb46/" - "processor_config.json", - "processor_config.json", true}, - {"https://huggingface.co/mlx-community/Qwen3.6-35B-A3B-4bit/resolve/" - "38740b847e4cb78f352aba30aa41c76e08e6eb46/" - "tokenizer.json", - "tokenizer.json", true}, - {"https://huggingface.co/mlx-community/Qwen3.6-35B-A3B-4bit/resolve/" - "38740b847e4cb78f352aba30aa41c76e08e6eb46/" - "tokenizer_config.json", - "tokenizer_config.json", true}, - {"https://huggingface.co/mlx-community/Qwen3.6-35B-A3B-4bit/resolve/" - "38740b847e4cb78f352aba30aa41c76e08e6eb46/" - "video_preprocessor_config.json", - "video_preprocessor_config.json", true}, - {"https://huggingface.co/mlx-community/Qwen3.6-35B-A3B-4bit/resolve/" - "38740b847e4cb78f352aba30aa41c76e08e6eb46/" - "vocab.json", - "vocab.json", true}, -}; - // mlx-community/Qwen3.8-27B-4bit (dense) — config.json model_type "qwen3_5", // registered in mlx-swift-lm 3.31.5's LLMTypeRegistry. constexpr CatalogFile kMlxQwen3_8_27BFiles[] = { @@ -1331,184 +1266,205 @@ constexpr int64_t MB = 1024LL * 1024LL; // test rig's LlamaCpp/qwen3-0.6b layout). constexpr CatalogEntry kCatalog[] = { // --- LLM (LlamaCpp / GGUF) --- + // Name carries Q8_0 on purpose: this is the one GGUF artifact in the + // catalog above 4-bit (verbatim from the consumer apps and matches the + // Linux test rig's layout -- see the file comment above kCatalog), so the + // display name must not claim the same "just the size" naming the <=4-bit + // entries use. Swap the URL for a verified <=4-bit artifact instead of + // relabeling if this is ever tightened to match the rest of the catalog. {"qwen3-0.6b", "qwen3", "Qwen3 0.6B Q8_0", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_LLAMA_CPP, v1::MODEL_FORMAT_GGUF, "https://huggingface.co/Qwen/Qwen3-0.6B-GGUF/resolve/main/" "Qwen3-0.6B-Q8_0.gguf", - nullptr, 0, 639 * MB, 4096, true}, - {"qwen3-1.7b-q4_k_m", "qwen3-1.7b", "Qwen3 1.7B Q4_K_M", - v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_LLAMA_CPP, - v1::MODEL_FORMAT_GGUF, - "https://huggingface.co/unsloth/Qwen3-1.7B-GGUF/resolve/main/" - "Qwen3-1.7B-Q4_K_M.gguf", - nullptr, 0, 1230 * MB, 4096, true}, - {"qwen3-4b-q4_k_m", "qwen3-4b", "Qwen3 4B Q4_K_M", - v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_LLAMA_CPP, - v1::MODEL_FORMAT_GGUF, - "https://huggingface.co/unsloth/Qwen3-4B-GGUF/resolve/main/" - "Qwen3-4B-Q4_K_M.gguf", - nullptr, 0, 2560 * MB, 4096, true}, + nullptr, 0, 639 * MB, 4096, true, 0, "", "qwen3-0.6b"}, // RunAnywhere's canonical-based llama.cpp fork supports PrismML's Q1_0 // Bonsai artifacts. Ternary-Bonsai uses the explicitly canonical // Q2_0_g64 artifacts below; legacy 128-value Q2_0 remains unsupported. // Exact artifact byte sizes. - {"bonsai-1.7b-q1_0", "bonsai-1.7b", "Bonsai-1.7B 1-bit Q1_0 (CPU)", + {"bonsai-1.7b", "bonsai-1.7b", "Bonsai 1.7B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_LLAMA_CPP, v1::MODEL_FORMAT_GGUF, "https://huggingface.co/prism-ml/Bonsai-1.7B-gguf/resolve/main/" "Bonsai-1.7B-Q1_0.gguf", - nullptr, 0, 248302272LL, 4096, true}, - {"bonsai-4b-q1_0", "bonsai-4b", "Bonsai-4B 1-bit Q1_0 (CPU)", + nullptr, 0, 248302272LL, 4096, true, 0, "", "bonsai-1.7b"}, + {"bonsai-4b", "bonsai-4b", "Bonsai 4B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_LLAMA_CPP, v1::MODEL_FORMAT_GGUF, "https://huggingface.co/prism-ml/Bonsai-4B-gguf/resolve/main/" "Bonsai-4B-Q1_0.gguf", - nullptr, 0, 572270624LL, 4096, true}, - {"bonsai-8b-q1_0", "bonsai-8b", "Bonsai-8B 1-bit Q1_0 (CPU)", + nullptr, 0, 572270624LL, 4096, true, 0, "", "bonsai-4b"}, + {"bonsai-8b", "bonsai-8b", "Bonsai 8B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_LLAMA_CPP, v1::MODEL_FORMAT_GGUF, "https://huggingface.co/prism-ml/Bonsai-8B-gguf/resolve/main/" "Bonsai-8B-Q1_0.gguf", - nullptr, 0, 1158654496LL, 4096, true}, - {"bonsai-27b-q1_0", "bonsai-27b", "Bonsai-27B 1-bit Q1_0 (CPU)", + nullptr, 0, 1158654496LL, 4096, true, 0, "", "bonsai-8b"}, + {"bonsai-27b", "bonsai-27b", "Bonsai 27B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_LLAMA_CPP, v1::MODEL_FORMAT_GGUF, "https://huggingface.co/prism-ml/Bonsai-27B-gguf/resolve/main/" "Bonsai-27B-Q1_0.gguf", - nullptr, 0, 3803452480LL, 4096, true}, - {"ternary-bonsai-1.7b-q2_0-g64", "ternary-bonsai-1.7b", - "Ternary-Bonsai-1.7B Q2_0 g64", v1::MODEL_CATEGORY_LANGUAGE, + nullptr, 0, 3803452480LL, 4096, true, 0, "", "bonsai-27b"}, + {"ternary-bonsai-1.7b", "ternary-bonsai-1.7b", + "Ternary-Bonsai 1.7B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_LLAMA_CPP, v1::MODEL_FORMAT_GGUF, "https://huggingface.co/prism-ml/Ternary-Bonsai-1.7B-gguf/resolve/" "983b5dec2ff16aab79990711ba0f828a499a7e6a/" "Ternary-Bonsai-1.7B-Q2_0_g64.gguf", - nullptr, 0, 490163968LL, 4096, true}, - {"ternary-bonsai-4b-q2_0-g64", "ternary-bonsai-4b", - "Ternary-Bonsai-4B Q2_0 g64", v1::MODEL_CATEGORY_LANGUAGE, + nullptr, 0, 490163968LL, 4096, true, 0, "", "ternary-bonsai-1.7b"}, + {"ternary-bonsai-4b", "ternary-bonsai-4b", + "Ternary-Bonsai 4B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_LLAMA_CPP, v1::MODEL_FORMAT_GGUF, "https://huggingface.co/prism-ml/Ternary-Bonsai-4B-gguf/resolve/" "a3eb42bafe873f9686bc97486c43b72ef7d75ec8/" "Ternary-Bonsai-4B-Q2_0_g64.gguf", - nullptr, 0, 1137806656LL, 4096, true}, - {"ternary-bonsai-8b-q2_0-g64", "ternary-bonsai-8b", - "Ternary-Bonsai-8B Q2_0 g64", v1::MODEL_CATEGORY_LANGUAGE, + nullptr, 0, 1137806656LL, 4096, true, 0, "", "ternary-bonsai-4b"}, + {"ternary-bonsai-8b", "ternary-bonsai-8b", + "Ternary-Bonsai 8B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_LLAMA_CPP, v1::MODEL_FORMAT_GGUF, "https://huggingface.co/prism-ml/Ternary-Bonsai-8B-gguf/resolve/" "c2aefbeb4b24469cd11579c3384b990404c17a30/" "Ternary-Bonsai-8B-Q2_0_g64.gguf", - nullptr, 0, 2310125920LL, 4096, true}, - {"maple-preview-tq1_0-q4_k", "maple-preview", - "DeepGrove Maple Preview TQ1_0 + Q4_K head (CPU)", + nullptr, 0, 2310125920LL, 4096, true, 0, "", "ternary-bonsai-8b"}, + {"maple-preview", "maple-preview", + "DeepGrove Maple Preview", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_LLAMA_CPP, v1::MODEL_FORMAT_GGUF, "https://huggingface.co/deepgrove/maple-preview-GGUF/resolve/" "f5466f918e0c50cdb9d4d47a6f35813509a42a30/" "maple-preview-TQ1_0-head-Q4_K.gguf", - nullptr, 0, 4984016416LL, 4096, true}, - {"llama-3.2-3b", "llama3.2", "Llama 3.2 3B Instruct Q4_K_M", + nullptr, 0, 4984016416LL, 4096, true, 0, "", "maple-preview"}, + {"llama-3.2-3b", "llama3.2", "Llama 3.2 3B Instruct", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_LLAMA_CPP, v1::MODEL_FORMAT_GGUF, "https://huggingface.co/bartowski/Llama-3.2-3B-Instruct-GGUF/resolve/main/" "Llama-3.2-3B-Instruct-Q4_K_M.gguf", nullptr, 0, 2020 * MB, 0, false}, - {"lfm2-350m-q8_0", "lfm2", "LiquidAI LFM2 350M Q8_0", + // LiquidAI LFM2.5 family (official GGUF, Apache 2.0). Replaces the older + // LFM2 Q8 entry: newer version, ≤4-bit, pinned revisions. 230M/350M also ship + // as ANE (Core ML) and NPU (Hexagon) bundles below, merged into one list row. + {"lfm2.5-230m", "lfm2.5-230m", "LiquidAI LFM2.5 230M", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_LLAMA_CPP, v1::MODEL_FORMAT_GGUF, - "https://huggingface.co/LiquidAI/LFM2-350M-GGUF/resolve/main/" - "LFM2-350M-Q8_0.gguf", - nullptr, 0, 400 * MB, 2048, false}, - {"smollm2-360m-q8_0", "smollm2", "SmolLM2 360M Q8_0", + "https://huggingface.co/LiquidAI/LFM2.5-230M-GGUF/resolve/" + "cdf97bd8205908758f44aec508d68ac1aef98f5c/" + "LFM2.5-230M-Q4_K_M.gguf", + nullptr, 0, 153406304LL, 32768, false, 0, "", "lfm2.5-230m"}, + {"lfm2.5-350m", "lfm2.5-350m", "LiquidAI LFM2.5 350M", + v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_LLAMA_CPP, + v1::MODEL_FORMAT_GGUF, + "https://huggingface.co/LiquidAI/LFM2.5-350M-GGUF/resolve/" + "9969000761ce34de907bf20017cbfc3d52d6eaf9/" + "LFM2.5-350M-Q4_K_M.gguf", + nullptr, 0, 219 * MB, 32768, false, 0, "", "lfm2.5-350m"}, + {"lfm2.5-1.2b", "lfm2.5", + "LiquidAI LFM2.5 1.2B Instruct", v1::MODEL_CATEGORY_LANGUAGE, + v1::INFERENCE_FRAMEWORK_LLAMA_CPP, v1::MODEL_FORMAT_GGUF, + "https://huggingface.co/LiquidAI/LFM2.5-1.2B-Instruct-GGUF/resolve/" + "6767265158422fb8a19c62ceb45f16f05363615b/" + "LFM2.5-1.2B-Instruct-Q4_K_M.gguf", + nullptr, 0, 697 * MB, 32768, false, 0, "", "lfm2.5-1.2b"}, + {"lfm2.5-2.6b", "lfm2.5-2.6b", "LiquidAI LFM2.5 2.6B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_LLAMA_CPP, v1::MODEL_FORMAT_GGUF, - "https://huggingface.co/prithivMLmods/SmolLM2-360M-GGUF/resolve/main/" - "SmolLM2-360M.Q8_0.gguf", - nullptr, 0, 386 * MB, 2048, false}, + "https://huggingface.co/LiquidAI/LFM2.5-2.6B-GGUF/resolve/" + "84022ce711b28455e8c4fc364ce68c00cf995875/" + "LFM2.5-2.6B-Q4_K_M.gguf", + nullptr, 0, 1597 * MB, 32768, false}, + // SmolLM2 135M from the llama.cpp org's own GGUF (official), ≤4-bit. + {"smollm2-135m", "smollm2", "SmolLM2 135M", + v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_LLAMA_CPP, + v1::MODEL_FORMAT_GGUF, + "https://huggingface.co/ggml-org/SmolLM2-135M-GGUF/resolve/" + "44686446221a479a9227d7a895cf92930f86de8a/" + "SmolLM2-135M-Q4_K_M.gguf", + nullptr, 0, 96 * MB, 8192, false}, // Google Gemma 4 family (GGUF). Licensed under Apache 2.0; preserve the // upstream license and attribution notices when redistributing. - {"gemma-4-e2b-it-q4_k_m", "gemma4-e2b", "Gemma 4 E2B IT Q4_K_M", + {"gemma-4-e2b", "gemma4-e2b", "Gemma 4 E2B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_LLAMA_CPP, v1::MODEL_FORMAT_GGUF, "https://huggingface.co/unsloth/gemma-4-E2B-it-GGUF/resolve/" "0314792d7f1f7e229411f620751375812bb9faf2/" "gemma-4-E2B-it-Q4_K_M.gguf", - nullptr, 0, 3106738272LL, 4096, false}, - {"gemma-4-e4b-it-q4_k_m", "gemma4-e4b", "Gemma 4 E4B IT Q4_K_M", + nullptr, 0, 3106738272LL, 4096, false, 0, "", "gemma-4-e2b"}, + {"gemma-4-e4b", "gemma4-e4b", "Gemma 4 E4B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_LLAMA_CPP, v1::MODEL_FORMAT_GGUF, "https://huggingface.co/unsloth/gemma-4-E4B-it-GGUF/resolve/" "bfc15c382204943c3a8fff0c750b94ae2364d7a3/" "gemma-4-E4B-it-Q4_K_M.gguf", - nullptr, 0, 4977171584LL, 4096, false}, - {"gemma-4-12b-it-q4_k_m", "gemma4-12b", "Gemma 4 12B IT Q4_K_M", + nullptr, 0, 4977171584LL, 4096, false, 0, "", "gemma-4-e4b"}, + {"gemma-4-12b", "gemma4-12b", "Gemma 4 12B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_LLAMA_CPP, v1::MODEL_FORMAT_GGUF, "https://huggingface.co/unsloth/gemma-4-12b-it-GGUF/resolve/" "fc034cfff751157913579611efad8462ac1be606/" "gemma-4-12b-it-Q4_K_M.gguf", - nullptr, 0, 7121861440LL, 4096, false}, - {"gemma-4-26b-a4b-it-q4_k_xl", "gemma4-26b-a4b", - "Gemma 4 26B-A4B IT UD-Q4_K_XL (MoE)", v1::MODEL_CATEGORY_LANGUAGE, + nullptr, 0, 7121861440LL, 4096, false, 0, "", "gemma-4-12b"}, + {"gemma-4-26b-a4b", "gemma4-26b-a4b", + "Gemma 4 26B-A4B (MoE)", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_LLAMA_CPP, v1::MODEL_FORMAT_GGUF, "https://huggingface.co/unsloth/gemma-4-26B-A4B-it-GGUF/resolve/" "c099eb48e663fd284577b04978a94ffccb261841/" "gemma-4-26B-A4B-it-UD-Q4_K_XL.gguf", - nullptr, 0, 17010980576LL, 4096, false}, - {"gemma-4-31b-it-q4_k_m", "gemma4-31b", "Gemma 4 31B IT Q4_K_M", + nullptr, 0, 17010980576LL, 4096, false, 0, "", "gemma-4-26b-a4b"}, + {"gemma-4-31b", "gemma4-31b", "Gemma 4 31B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_LLAMA_CPP, v1::MODEL_FORMAT_GGUF, "https://huggingface.co/unsloth/gemma-4-31B-it-GGUF/resolve/" "c1ac76e99d5513b141e8adde7288b85c3f9c32ec/" "gemma-4-31B-it-Q4_K_M.gguf", - nullptr, 0, 18323733440LL, 4096, false}, - // Smaller quant of the same 31B model for tighter RAM budgets. - {"gemma-4-31b-it-ud-q2_k_xl", "gemma4-31b-q2", "Gemma 4 31B IT UD-Q2_K_XL", - v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_LLAMA_CPP, - v1::MODEL_FORMAT_GGUF, - "https://huggingface.co/unsloth/gemma-4-31B-it-GGUF/resolve/" - "c1ac76e99d5513b141e8adde7288b85c3f9c32ec/" - "gemma-4-31B-it-UD-Q2_K_XL.gguf", - nullptr, 0, 11774991296LL, 4096, false}, + nullptr, 0, 18323733440LL, 4096, false, 0, "", "gemma-4-31b"}, - // Qwen3.6-35B-A3B (MoE, agentic-coding, Apache 2.0). - {"qwen3.6-35b-a3b-q4_k_m", "qwen3.6-35b", "Qwen3.6 35B-A3B UD-Q4_K_M (MoE)", - v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_LLAMA_CPP, - v1::MODEL_FORMAT_GGUF, - "https://huggingface.co/unsloth/Qwen3.6-35B-A3B-GGUF/resolve/" - "a483e9e6cbd595906af30beda3187c2663a1118c/" - "Qwen3.6-35B-A3B-UD-Q4_K_M.gguf", - nullptr, 0, 22134528992LL, 4096, true}, // Qwen3.8-27B (dense, newest Qwen, Apache 2.0). - {"qwen3.8-27b-q4_k_m", "qwen3.8-27b", "Qwen3.8 27B Q4_K_M", + {"qwen3.8-27b", "qwen3.8-27b", "Qwen3.8 27B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_LLAMA_CPP, v1::MODEL_FORMAT_GGUF, "https://huggingface.co/unsloth/Qwen3.8-27B-GGUF/resolve/" "f1bfb127c64f7072bdd2cad55f258b9c8b2910fe/" "Qwen3.8-27B-Q4_K_M.gguf", - nullptr, 0, 17106775008LL, 4096, true}, + nullptr, 0, 17106775008LL, 262144, true, 0, "", "qwen3.8-27b"}, // IBM Granite 4.1 family (Apache 2.0). - {"granite-4.1-3b-q4_k_m", "granite4.1-3b", "IBM Granite 4.1 3B Q4_K_M", + {"granite-4.1-3b", "granite4.1-3b", "IBM Granite 4.1 3B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_LLAMA_CPP, v1::MODEL_FORMAT_GGUF, "https://huggingface.co/unsloth/granite-4.1-3b-GGUF/resolve/" "5b88826e4b80789548180f8faab39c5cf68772c9/" "granite-4.1-3b-Q4_K_M.gguf", - nullptr, 0, 2099502400LL, 4096, false}, - {"granite-4.1-8b-q4_k_m", "granite4.1-8b", "IBM Granite 4.1 8B Q4_K_M", + nullptr, 0, 2099502400LL, 4096, false, 0, "", "granite-4.1-3b"}, + {"granite-4.1-8b", "granite4.1-8b", "IBM Granite 4.1 8B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_LLAMA_CPP, v1::MODEL_FORMAT_GGUF, "https://huggingface.co/unsloth/granite-4.1-8b-GGUF/resolve/" "6f9671f73eb03273bc09319194b8a4e810e03a8f/" "granite-4.1-8b-Q4_K_M.gguf", - nullptr, 0, 5347915136LL, 4096, false}, - {"granite-4.1-30b-q4_k_m", "granite4.1-30b", "IBM Granite 4.1 30B Q4_K_M", + nullptr, 0, 5347915136LL, 4096, false, 0, "", "granite-4.1-8b"}, + {"granite-4.1-30b", "granite4.1-30b", "IBM Granite 4.1 30B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_LLAMA_CPP, v1::MODEL_FORMAT_GGUF, "https://huggingface.co/unsloth/granite-4.1-30b-GGUF/resolve/" "6cb34f31b11ca4c1433de1af7391dac46de4e666/" "granite-4.1-30b-Q4_K_M.gguf", - nullptr, 0, 17490241472LL, 4096, false}, + nullptr, 0, 17490241472LL, 4096, false, 0, "", "granite-4.1-30b"}, + + // IBM Granite 4.2 family (bartowski GGUF, Apache 2.0) — newest Granite. + {"granite-4.2-8b", "granite4.2-8b", "IBM Granite 4.2 8B", + v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_LLAMA_CPP, + v1::MODEL_FORMAT_GGUF, + "https://huggingface.co/bartowski/granite-4.2-8b-GGUF/resolve/" + "a592100df8fe4931c7cffbac7b28e8176a1d52da/" + "granite-4.2-8b-Q4_K_M.gguf", + nullptr, 0, 5283 * MB, 131072, false}, + {"granite-4.2-30b", "granite4.2-30b", "IBM Granite 4.2 30B", + v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_LLAMA_CPP, + v1::MODEL_FORMAT_GGUF, + "https://huggingface.co/bartowski/granite-4.2-30b-GGUF/resolve/" + "1847d3b70241af9d656f382a4cf29d5c6573e584/" + "granite-4.2-30b-Q4_K_M.gguf", + nullptr, 0, 17192 * MB, 131072, false}, // --- VLM (gguf + mmproj pairs) --- {"smolvlm2-256m-video-instruct-q8_0", "smolvlm2", @@ -1665,8 +1621,14 @@ constexpr CatalogEntry kCatalog[] = { // --- Image generation (CoreML diffusion; Apple only) --- // Apple-optimized Stable Diffusion 1.5. Id matches the built-in diffusion // model registry (diffusion_model_registry.cpp) and the Swift facade's - // canonical `.imageGeneration` model, so `wally image generate` resolves it - // and `wally list` shows it. The palettized CoreML bundle is a directory of + // canonical `.imageGeneration` model, so `wally image generate` resolves + // and auto-pulls it through that SDK-side registry regardless of this + // catalog. IMAGE_GENERATION is not is_llm(), so the LLM-only cut means + // this entry is never registered by register_all() and never appears in + // `wally models list` (with or without --all), nor does it resolve + // through `wally models pull ` -- it stays here only as the + // documented source of its metadata for `wally image generate`'s default. + // The palettized CoreML bundle is a directory of // compiled .mlmodelc sub-models served by the `coreml` engine; a // pre-fetched bundle can also be passed to `--model` as a local path. // The Hugging Face *repo page* is HTML (~160 KB) and is not a model. @@ -1679,18 +1641,24 @@ constexpr CatalogEntry kCatalog[] = { "coreml-stable-diffusion-v1-5-palettized_split_einsum_v2_compiled.zip", nullptr, 0, 1500 * MB, 0, false}, // NeuRT advertises LLM + STT + EMBED + RERANK + VLM + EMBED_IMAGE + DIFFUSION; folder refs (same ModelInfo - // path as sd15). Pass a local compiled tree to `--model` — `wally pull` of a + // path as sd15). Pass a local compiled tree to `--model` — `wally models pull` of a // Hugging Face repo page is HTML, not a bundle. - {"lfm2_5_230m_ane", "lfm2-230m-ane", "LFM2.5 230M (Apple Neural Engine)", - v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_COREML, - v1::MODEL_FORMAT_MLPACKAGE, - "https://huggingface.co/runanywhere/LFM2.5-230M_ANE", nullptr, 0, 0, 0, - false}, - {"lfm2_5_350m_ane", "lfm2-350m-ane", "LFM2.5 350M (Apple Neural Engine)", - v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_COREML, - v1::MODEL_FORMAT_MLPACKAGE, - "https://huggingface.co/runanywhere/LFM2.5-350M_ANE", nullptr, 0, 0, 0, - false}, + // TEMP(ane-cut): the two ANE LLM rows are out of the release. Both URLs + // are Hugging Face repo *pages* (the repos hold fp16/ and int8/ trees, no + // archive), so `models pull` cannot fetch them, and the public kit has no + // NeuRT engine to run them. Uncomment this block, the `ane-` prefix in + // find() below, and the test row in tests/test_wally_unit.cpp together + // once real artifacts exist; nothing else has to change. + // {"lfm2_5_230m_ane", "lfm2-230m-ane", "LiquidAI LFM2.5 230M", + // v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_COREML, + // v1::MODEL_FORMAT_MLPACKAGE, + // "https://huggingface.co/runanywhere/LFM2.5-230M_ANE", nullptr, 0, 0, 0, + // false, 0, "", "lfm2.5-230m"}, + // {"lfm2_5_350m_ane", "lfm2-350m-ane", "LiquidAI LFM2.5 350M", + // v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_COREML, + // v1::MODEL_FORMAT_MLPACKAGE, + // "https://huggingface.co/runanywhere/LFM2.5-350M_ANE", nullptr, 0, 0, 0, + // false, 0, "", "lfm2.5-350m"}, // The first ANE EMBEDDING row. docs/BUNDLE_CONTRACT.md listed this exact bundle as the one // that "loads, undrivable" — its manifest parsed and its encoder graph bound, but the SDK's // neurt engine filled no embedding_ops, so nothing could drive it. Gate B on an M4 Max: @@ -1754,7 +1722,7 @@ constexpr CatalogEntry kCatalog[] = { v1::MODEL_CATEGORY_SPEECH_SYNTHESIS, v1::INFERENCE_FRAMEWORK_COREML, v1::MODEL_FORMAT_MLPACKAGE, // The .zip, NOT the repo root. A bare huggingface.co// URL makes - // `wally pull` fetch the repo's HTML PAGE -- 120 KB of markup written to disk + // `wally models pull` fetch the repo's HTML PAGE -- 120 KB of markup written to disk // under the model id, with a cheerful "done 100%". Every other ANE row here // still has that shape and is therefore listable but not pullable. "https://huggingface.co/runanywhere/Kokoro-82M_ANE/resolve/main/" @@ -1768,70 +1736,78 @@ constexpr CatalogEntry kCatalog[] = { 0, 0, false}, // --- MLX (Apple Silicon / Apple GPU via mlx-swift-lm) --- - {"mlx-qwen3-0.6b-4bit", "mlx-qwen3", "Qwen3 0.6B 4-bit (MLX)", + {"mlx-qwen3-0.6b-4bit", "mlx-qwen3", "Qwen3 0.6B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_MLX, v1::MODEL_FORMAT_SAFETENSORS, nullptr, kMlxQwen3_06BFiles, 9, 351383618, - 4096, true}, + 4096, true, 0, "", "qwen3-0.6b"}, {"mlx-maple-preview-2bit", "mlx-maple-preview", - "DeepGrove Maple Preview 2-bit (MLX)", v1::MODEL_CATEGORY_LANGUAGE, + "DeepGrove Maple Preview", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_MLX, v1::MODEL_FORMAT_SAFETENSORS, nullptr, - kMlxMaplePreviewFiles, 13, 5330252282LL, 128000, true}, + kMlxMaplePreviewFiles, 13, 5330252282LL, 128000, true, 0, "", + "maple-preview"}, {"mlx-llama-3.1-nemotron-nano-8b-v1-4bit", "mlx-nemotron-nano", - "NVIDIA Llama 3.1 Nemotron Nano 8B 4-bit (MLX)", + "NVIDIA Llama 3.1 Nemotron Nano 8B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_MLX, v1::MODEL_FORMAT_SAFETENSORS, nullptr, kMlxNemotronNano8BFiles, 8, - 4534806075LL, 131072, false}, + 4534806075LL, 131072, false, 0, "", "mlx-nemotron-nano"}, {"mlx-nemotron-mini-4b-instruct-4bit", "mlx-nemotron-mini", - "NVIDIA Nemotron Mini 4B Instruct 4-bit (MLX)", + "NVIDIA Nemotron Mini 4B Instruct", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_MLX, v1::MODEL_FORMAT_SAFETENSORS, nullptr, kMlxNemotronMini4BFiles, 6, - 2392679103LL, 4096, false}, + 2392679103LL, 4096, false, 0, "", "mlx-nemotron-mini"}, // PrismML Bonsai family 1-bit MLX. Needs the narrow Prism kernels carried // by the canonical-first RunAnywhere MLX/mlx-swift forks pinned in the // Swift manifests and resolved files. - {"mlx-bonsai-1.7b-1bit", "mlx-bonsai-1.7b", "MLX Bonsai-1.7B 1-bit", + {"mlx-bonsai-1.7b-1bit", "mlx-bonsai-1.7b", "Bonsai 1.7B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_MLX, v1::MODEL_FORMAT_SAFETENSORS, nullptr, kMlxBonsai1_7B1BitFiles, 8, - 269060904LL, 4096, true}, - {"mlx-bonsai-4b-1bit", "mlx-bonsai-4b", "MLX Bonsai-4B 1-bit", + 269060904LL, 4096, true, 0, "", "bonsai-1.7b"}, + {"mlx-bonsai-4b-1bit", "mlx-bonsai-4b", "Bonsai 4B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_MLX, v1::MODEL_FORMAT_SAFETENSORS, nullptr, kMlxBonsai4B1BitFiles, 8, - 628865840LL, 4096, true}, - {"mlx-bonsai-8b-1bit", "mlx-bonsai-8b", "MLX Bonsai-8B 1-bit", + 628865840LL, 4096, true, 0, "", "bonsai-4b"}, + {"mlx-bonsai-8b-1bit", "mlx-bonsai-8b", "Bonsai 8B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_MLX, v1::MODEL_FORMAT_SAFETENSORS, nullptr, kMlxBonsai8B1BitFiles, 8, - 1280131424LL, 4096, true}, + 1280131424LL, 4096, true, 0, "", "bonsai-8b"}, // PrismML Bonsai-27B 1-bit MLX (~5.1 GB safetensors). Experimental — // requires mlx-swift-lm support for qwen3_5 / 1-bit Bonsai. - {"mlx-bonsai-27b-1bit", "mlx-bonsai", "MLX Bonsai-27B 1-bit", + {"mlx-bonsai-27b-1bit", "mlx-bonsai", "Bonsai 27B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_MLX, v1::MODEL_FORMAT_SAFETENSORS, nullptr, kMlxBonsai27B1BitFiles, 8, - 5129115752LL, 4096, true}, + 5129115752LL, 4096, true, 0, "", "bonsai-27b"}, // PrismML Ternary-Bonsai family at ternary/2-bit MLX. bits=2 was already // supported by upstream MLX 0.31.6 before the Prism 1-bit patch, so this // needs no additional fork support beyond what Bonsai (above) needs. // Verified this session: loaded + generated correctly via the app's // Add-from-URL flow (Ternary-Bonsai-1.7B, 64 tok/s, no crash). {"mlx-ternary-bonsai-1.7b-2bit", "mlx-ternary-bonsai-1.7b", - "MLX Ternary-Bonsai-1.7B 2-bit", v1::MODEL_CATEGORY_LANGUAGE, + "Ternary-Bonsai 1.7B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_MLX, v1::MODEL_FORMAT_SAFETENSORS, nullptr, - kMlxTernaryBonsai1_7B2BitFiles, 6, 484049216LL, 4096, true}, + kMlxTernaryBonsai1_7B2BitFiles, 6, 484049216LL, 4096, true, 0, "", + "ternary-bonsai-1.7b"}, {"mlx-ternary-bonsai-4b-2bit", "mlx-ternary-bonsai-4b", - "MLX Ternary-Bonsai-4B 2-bit", v1::MODEL_CATEGORY_LANGUAGE, + "Ternary-Bonsai 4B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_MLX, v1::MODEL_FORMAT_SAFETENSORS, nullptr, - kMlxTernaryBonsai4B2BitFiles, 6, 1131565944LL, 4096, true}, + kMlxTernaryBonsai4B2BitFiles, 6, 1131565944LL, 4096, true, 0, "", + "ternary-bonsai-4b"}, {"mlx-ternary-bonsai-8b-2bit", "mlx-ternary-bonsai-8b", - "MLX Ternary-Bonsai-8B 2-bit", v1::MODEL_CATEGORY_LANGUAGE, + "Ternary-Bonsai 8B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_MLX, v1::MODEL_FORMAT_SAFETENSORS, nullptr, - kMlxTernaryBonsai8B2BitFiles, 6, 2303661704LL, 4096, true}, + kMlxTernaryBonsai8B2BitFiles, 6, 2303661704LL, 4096, true, 0, "", + "ternary-bonsai-8b"}, + // merge_key matches the bare id, same as every other Ternary-Bonsai size + // above (1.7b/4b/8b) -- not the "mlx-" prefixed alias -- so a future GGUF + // Ternary-Bonsai-27B row merges into this one row instead of listing twice. {"mlx-ternary-bonsai-27b-2bit", "mlx-ternary-bonsai-27b", - "MLX Ternary-Bonsai-27B 2-bit", v1::MODEL_CATEGORY_LANGUAGE, + "Ternary-Bonsai 27B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_MLX, v1::MODEL_FORMAT_SAFETENSORS, nullptr, - kMlxTernaryBonsai27B2BitFiles, 8, 8490785104LL, 4096, true}, + kMlxTernaryBonsai27B2BitFiles, 8, 8490785104LL, 4096, true, 0, "", + "ternary-bonsai-27b"}, {"mlx-llama-3.2-1b-instruct-4bit", "mlx-llama3.2", - "Llama 3.2 1B Instruct 4-bit (MLX)", v1::MODEL_CATEGORY_LANGUAGE, + "Llama 3.2 1B Instruct", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_MLX, v1::MODEL_FORMAT_SAFETENSORS, nullptr, - kMlxLlama32_1BFiles, 6, 712575975, 0, false}, + kMlxLlama32_1BFiles, 6, 712575975, 0, false, 0, "", "mlx-llama3.2"}, {"mlx-qwen2-vl-2b-instruct-4bit", "mlx-qwen2-vl", "Qwen2-VL 2B Instruct 4-bit (MLX)", v1::MODEL_CATEGORY_MULTIMODAL, v1::INFERENCE_FRAMEWORK_MLX, v1::MODEL_FORMAT_SAFETENSORS, nullptr, @@ -1892,58 +1868,56 @@ constexpr CatalogEntry kCatalog[] = { // 3.31.5 LLMTypeRegistry/VLMTypeRegistry — verified by reading the // checked-out package source this session (not assumed). Licensed under // Apache 2.0; preserve the upstream license and attribution notices. - {"mlx-gemma-4-e2b-it-4bit", "mlx-gemma4-e2b", "Gemma 4 E2B IT 4-bit (MLX)", + {"mlx-gemma-4-e2b-it-4bit", "mlx-gemma4-e2b", "Gemma 4 E2B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_MLX, v1::MODEL_FORMAT_SAFETENSORS, nullptr, kMlxGemma4E2BFiles, 8, 3550670554LL, - 4096, false}, + 4096, false, 0, "", "gemma-4-e2b"}, {"mlx-gemma-4-e4b-it-qat-4bit", "mlx-gemma4-e4b", - "Gemma 4 E4B IT QAT 4-bit (MLX)", v1::MODEL_CATEGORY_LANGUAGE, + "Gemma 4 E4B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_MLX, v1::MODEL_FORMAT_SAFETENSORS, nullptr, - kMlxGemma4E4BFiles, 9, 6798307742LL, 4096, false}, + kMlxGemma4E4BFiles, 9, 6798307742LL, 4096, false, 0, "", "gemma-4-e4b"}, {"mlx-gemma-4-12b-it-qat-4bit", "mlx-gemma4-12b", - "Gemma 4 12B IT QAT 4-bit (MLX)", v1::MODEL_CATEGORY_LANGUAGE, + "Gemma 4 12B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_MLX, v1::MODEL_FORMAT_SAFETENSORS, nullptr, - kMlxGemma4_12BFiles, 10, 10987772430LL, 4096, false}, + kMlxGemma4_12BFiles, 10, 10987772430LL, 4096, false, 0, "", "gemma-4-12b"}, {"mlx-gemma-4-26b-a4b-it-4bit", "mlx-gemma4-26b-a4b", - "Gemma 4 26B-A4B IT 4-bit (MLX, MoE)", v1::MODEL_CATEGORY_LANGUAGE, + "Gemma 4 26B-A4B (MoE)", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_MLX, v1::MODEL_FORMAT_SAFETENSORS, nullptr, - kMlxGemma4_26BA4BFiles, 10, 15341205776LL, 4096, false}, + kMlxGemma4_26BA4BFiles, 10, 15341205776LL, 4096, false, 0, "", + "gemma-4-26b-a4b"}, // The plain 4bit variant, NOT "-qat-4bit" — that name does not resolve to // a clean repo (verified this session); this is the largest dense Gemma 4. - {"mlx-gemma-4-31b-it-4bit", "mlx-gemma4-31b", "Gemma 4 31B IT 4-bit (MLX)", + {"mlx-gemma-4-31b-it-4bit", "mlx-gemma4-31b", "Gemma 4 31B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_MLX, v1::MODEL_FORMAT_SAFETENSORS, nullptr, kMlxGemma4_31BFiles, 11, - 18412016676LL, 4096, false}, + 18412016676LL, 4096, false, 0, "", "gemma-4-31b"}, - // Qwen3.6-35B-A3B (MoE) — config.json model_type "qwen3_5_moe", - // registered in mlx-swift-lm 3.31.5's LLMTypeRegistry. - {"mlx-qwen3.6-35b-a3b-4bit", "mlx-qwen3.6-35b", - "Qwen3.6 35B-A3B 4-bit (MLX, MoE)", v1::MODEL_CATEGORY_LANGUAGE, - v1::INFERENCE_FRAMEWORK_MLX, v1::MODEL_FORMAT_SAFETENSORS, nullptr, - kMlxQwen3_6_35BA3BFiles, 15, 20402204271LL, 4096, true}, // Qwen3.8-27B (dense) — config.json model_type "qwen3_5", registered. - {"mlx-qwen3.8-27b-4bit", "mlx-qwen3.8-27b", "Qwen3.8 27B 4-bit (MLX)", + {"mlx-qwen3.8-27b-4bit", "mlx-qwen3.8-27b", "Qwen3.8 27B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_MLX, v1::MODEL_FORMAT_SAFETENSORS, nullptr, kMlxQwen3_8_27BFiles, 13, - 16054541349LL, 4096, true}, + 16054541349LL, 262144, true, 0, "", "qwen3.8-27b"}, // IBM Granite 4.1 family (MLX). config.json model_type "granite", // registered in mlx-swift-lm 3.31.5's LLMTypeRegistry. {"mlx-granite-4.1-3b-4bit", "mlx-granite4.1-3b", - "IBM Granite 4.1 3B 4-bit (MLX)", v1::MODEL_CATEGORY_LANGUAGE, + "IBM Granite 4.1 3B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_MLX, v1::MODEL_FORMAT_SAFETENSORS, nullptr, - kMlxGranite4_1_3BFiles, 7, 2127162429LL, 4096, false}, + kMlxGranite4_1_3BFiles, 7, 2127162429LL, 4096, false, 0, "", + "granite-4.1-3b"}, // A real, official mlx-community 8B 4-bit quant does exist (Apache-2.0, // model_type "granite") — verified via HF API this session, despite the // original assumption that none did; added for parity with 3B/30B. {"mlx-granite-4.1-8b-4bit", "mlx-granite4.1-8b", - "IBM Granite 4.1 8B 4-bit (MLX)", v1::MODEL_CATEGORY_LANGUAGE, + "IBM Granite 4.1 8B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_MLX, v1::MODEL_FORMAT_SAFETENSORS, nullptr, - kMlxGranite4_1_8BFiles, 7, 5238406779LL, 4096, false}, + kMlxGranite4_1_8BFiles, 7, 5238406779LL, 4096, false, 0, "", + "granite-4.1-8b"}, {"mlx-granite-4.1-30b-4bit", "mlx-granite4.1-30b", - "IBM Granite 4.1 30B 4-bit (MLX)", v1::MODEL_CATEGORY_LANGUAGE, + "IBM Granite 4.1 30B", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_MLX, v1::MODEL_FORMAT_SAFETENSORS, nullptr, - kMlxGranite4_1_30BFiles, 10, 18041976573LL, 4096, false}, + kMlxGranite4_1_30BFiles, 10, 18041976573LL, 4096, false, 0, "", + "granite-4.1-30b"}, // --- QHexRT (Snapdragon Hexagon NPU; Windows ARM64 overlay) --- // Ids match engines/qhexrt/qhexrt_model_catalog.cpp so pull/lifecycle @@ -1951,29 +1925,24 @@ constexpr CatalogEntry kCatalog[] = { // URLs are registered as ModelInfo (same path as CoreML diffusion) — // the QNN context tree is fetched by the QHexRT bundle policy or passed // as a local `*_HNPU` directory to `wally run`. - {"lfm2_5_230m", "lfm2-230m-npu", "LFM2.5 230M (Hexagon NPU)", + {"lfm2_5_230m", "lfm2-230m-npu", "LiquidAI LFM2.5 230M", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_QHEXRT, v1::MODEL_FORMAT_QNN_CONTEXT, "https://huggingface.co/runanywhere/lfm2_5_230m_HNPU", nullptr, 0, 0, 0, - false}, - {"lfm2_5_350m", "lfm2-350m-npu", "LFM2.5 350M (Hexagon NPU)", + false, 0, "", "lfm2.5-230m"}, + {"lfm2_5_350m", "lfm2-350m-npu", "LiquidAI LFM2.5 350M", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_QHEXRT, v1::MODEL_FORMAT_QNN_CONTEXT, "https://huggingface.co/runanywhere/lfm2_5_350m_HNPU", nullptr, 0, 0, 0, - false}, + false, 0, "", "lfm2.5-350m"}, {"lfm2_5_1_2b_thinking", "lfm2-1.2b-npu", - "LFM2.5 1.2B Thinking (Hexagon NPU)", v1::MODEL_CATEGORY_LANGUAGE, + "LiquidAI LFM2.5 1.2B Thinking", v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_QHEXRT, v1::MODEL_FORMAT_QNN_CONTEXT, "https://huggingface.co/runanywhere/lfm2_5_1_2b_thinking_HNPU", nullptr, 0, - 0, 0, true}, - {"qwen3_5_2b", "qwen3.5-2b-npu", "Qwen3.5 2B (Hexagon NPU)", - v1::MODEL_CATEGORY_LANGUAGE, v1::INFERENCE_FRAMEWORK_QHEXRT, - v1::MODEL_FORMAT_QNN_CONTEXT, - "https://huggingface.co/runanywhere/qwen3_5_2b_HNPU", nullptr, 0, 0, 0, - false}, + 0, 0, true, 0, "", "lfm2.5-1.2b"}, // Non-LLM Hexagon primitives. Ids match engines/qhexrt/qhexrt_model_catalog.cpp. // Same folder-URL registration as the LLM rows — pass a local `*_HNPU` - // directory; do not expect `wally pull` to fetch the HF repo HTML. + // directory; do not expect `wally models pull` to fetch the HF repo HTML. {"whisper_base", "whisper-base-npu", "Whisper Base (Hexagon NPU)", v1::MODEL_CATEGORY_SPEECH_RECOGNITION, v1::INFERENCE_FRAMEWORK_QHEXRT, v1::MODEL_FORMAT_QNN_CONTEXT, @@ -2022,19 +1991,19 @@ constexpr CatalogEntry kCatalog[] = { // (llama.cpp) remain the way to run these two on wally. }; -constexpr size_t kCatalogCount = sizeof(kCatalog) / sizeof(kCatalog[0]); - rac_result_t register_entry(const CatalogEntry &entry) { // CoreML bundles (a directory of compiled .mlmodelc sub-models) don't fit the // URL / multi-file download-factory grammar, which rejects a bare repo ref. // Register the ModelInfo directly so the id resolves in the general registry - // (and `wally list` shows it); the bundle itself is fetched by the diffusion - // pipeline or supplied to `wally image --model `. + // (and `wally models list --all` shows it, since it is catalog-only until + // downloaded); the bundle itself is fetched by the diffusion pipeline or + // supplied to `wally image --model `. if (entry.framework == v1::INFERENCE_FRAMEWORK_COREML || entry.framework == v1::INFERENCE_FRAMEWORK_QHEXRT) { // CoreML bundles and QHexRT HNPU folders don't fit the single-file - // download-factory grammar. Register ModelInfo so `wally list` / `wally run` - // resolve the id; the tree is fetched by the engine or passed as a local path. + // download-factory grammar. Register ModelInfo so `wally models list --all` + // / `wally run` resolve the id; the tree is fetched by the engine or passed + // as a local path. v1::ModelInfo model; model.set_id(entry.id); model.set_name(entry.name); @@ -2118,20 +2087,125 @@ rac_result_t register_entry(const CatalogEntry &entry) { } // namespace +// LLM-only cut: the catalog surfaces language models only. Every other +// modality's entries still live in kCatalog above, but are filtered out here, +// so `models list`, lookups, suggestions and SDK registration all see LLMs +// only. Delete is_llm and its four uses below to restore the full catalog. +static bool is_llm(const CatalogEntry &entry) { + return entry.category == runanywhere::v1::MODEL_CATEGORY_LANGUAGE; +} + +// MLX is an Apple-only backend. On any other platform its entries are hidden +// and never registered, so a Windows or Linux user cannot list, resolve, or +// download a model they could never run. +// llama.cpp, the Apple Neural Engine (Core ML via NeuRT) and QHexRT are gated +// by the linked kit's own capability macros rather than by host OS/arch: +// WALLY_HAS_LLAMACPP / WALLY_HAS_NEURT / WALLY_HAS_QHEXRT come from +// wally_define_engine_macros() (cmake/RunAnywhereSDK.cmake), set from the +// consumed kit's RunAnywhere_HAS_* config. The public windows-arm64 kit ships +// no llama.cpp backend (docs/ENGINES.md); NeuRT and QHexRT are private overlay +// packs (AGENTS.md), so the public Apple kit has no engine that can load a +// Core ML LLM even though the host is a Mac. Gating ANE on __APPLE__ used to +// list `ane-lfm2.5-350m` on that kit: `models pull` saved the Hugging Face repo +// page as the model and `run` then handed the folder to MLX, which failed on a +// missing config.json. Reading the linked kit's own macros tracks the real +// per-build matrix instead of guessing it from __APPLE__/_WIN32. +static bool platform_supports(runanywhere::v1::InferenceFramework framework) { + if (framework == runanywhere::v1::INFERENCE_FRAMEWORK_MLX) { +#if defined(__APPLE__) + return true; +#else + return false; +#endif + } + if (framework == runanywhere::v1::INFERENCE_FRAMEWORK_COREML) { +#if defined(WALLY_HAS_NEURT) + return true; +#else + return false; +#endif + } + if (framework == runanywhere::v1::INFERENCE_FRAMEWORK_LLAMA_CPP) { +#if defined(WALLY_HAS_LLAMACPP) + return true; +#else + return false; +#endif + } + if (framework == runanywhere::v1::INFERENCE_FRAMEWORK_QHEXRT) { +#if defined(WALLY_HAS_QHEXRT) + return true; +#else + return false; +#endif + } + return true; +} + +// The one predicate every surface filters on: an LLM this platform can run. +static bool listed(const CatalogEntry &entry) { + return is_llm(entry) && platform_supports(entry.framework); +} + const CatalogEntry *all(size_t *count) { + // A contiguous, LLM-only view built once; callers get the same stable + // pointer + count contract they had against kCatalog. + static const std::vector llm_only = [] { + std::vector filtered; + for (const CatalogEntry &entry : kCatalog) { + if (listed(entry)) { + filtered.push_back(entry); + } + } + return filtered; + }(); if (count) { - *count = kCatalogCount; + *count = llm_only.size(); } - return kCatalog; + return llm_only.data(); } const CatalogEntry *find(const std::string &id_or_alias) { for (const CatalogEntry &entry : kCatalog) { + if (!listed(entry)) { + continue; + } if (id_or_alias == entry.id || (entry.alias && id_or_alias == entry.alias)) { return &entry; } } + + // Predictable per-backend names for a merged row. `models list` shows one id + // per model (the shared merge_key); each backend's build is that id with a + // backend prefix — `mlx-`, `ane-`, `npu-` — so a reader never has + // to guess the old alias. Only reached when the exact match above missed. + static constexpr struct { + const char *prefix; + v1::InferenceFramework framework; + } kBackendPrefixes[] = { + {"mlx-", v1::INFERENCE_FRAMEWORK_MLX}, + // TEMP(ane-cut): no ANE rows are listed, so `ane-` resolves to + // nothing. Restore with the rows above. + // {"ane-", v1::INFERENCE_FRAMEWORK_COREML}, + {"npu-", v1::INFERENCE_FRAMEWORK_QHEXRT}, + }; + for (const auto &prefixed : kBackendPrefixes) { + const std::string prefix = prefixed.prefix; + if (id_or_alias.rfind(prefix, 0) != 0) { + continue; + } + const std::string base = id_or_alias.substr(prefix.size()); + for (const CatalogEntry &entry : kCatalog) { + if (!listed(entry) || entry.framework != prefixed.framework) { + continue; + } + const char *key = entry.merge_key ? entry.merge_key : entry.id; + if (base == key) { + return &entry; + } + } + } return nullptr; } @@ -2141,6 +2215,9 @@ std::vector suggestions(const std::string &input, size_t max) { if (matches.size() >= max) { break; } + if (!listed(entry)) { + continue; + } if (std::string(entry.id).find(input) != std::string::npos || (entry.alias && std::string(entry.alias).find(input) != std::string::npos)) { @@ -2150,9 +2227,21 @@ std::vector suggestions(const std::string &input, size_t max) { return matches; } +std::string merge_key_for(const std::string &id) { + for (const CatalogEntry &entry : kCatalog) { + if (id == entry.id) { + return entry.merge_key ? entry.merge_key : entry.id; + } + } + return id; +} + rac_result_t register_all() { rac_result_t first_error = RAC_SUCCESS; for (const CatalogEntry &entry : kCatalog) { + if (!listed(entry)) { + continue; + } const rac_result_t rc = register_entry(entry); if (rc != RAC_SUCCESS) { out::status_line( diff --git a/src/catalog/catalog.h b/src/catalog/catalog.h index 1e2ae9e7..d6235b17 100644 --- a/src/catalog/catalog.h +++ b/src/catalog/catalog.h @@ -48,6 +48,10 @@ struct CatalogEntry { bool supports_thinking; int64_t memory_required_bytes = 0; // 0 = unknown/not applicable const char *cua_profile = ""; // Computer-Use-Agent profile id ("" = none) + // Shared base for the same model across backends (llama.cpp / MLX / ANE / NPU). + // nullptr → the row stands alone under its own id. `models list` groups by this + // and joins the backends into one row (e.g. "mlx/llama.cpp"). + const char *merge_key = nullptr; }; /** All built-in entries. */ @@ -59,6 +63,14 @@ const CatalogEntry *find(const std::string &id_or_alias); /** Closest-match candidates for error messages (substring match, ≤ max). */ std::vector suggestions(const std::string &input, size_t max); +/** + * The merge base for a registry id: the entry's merge_key when set, else the id + * itself (unchanged when the id is not a catalog entry). Raw exact lookup with no + * platform preference — used by `models list` to collapse a model's per-backend + * variants into one row. + */ +std::string merge_key_for(const std::string &id); + /** * Register every entry with the global model registry. Logs (does not fail * on) individual rejections so one bad entry can't take the CLI down. diff --git a/src/catalog/model_ref.cpp b/src/catalog/model_ref.cpp index 9a9f9b4f..d48558f9 100644 --- a/src/catalog/model_ref.cpp +++ b/src/catalog/model_ref.cpp @@ -348,7 +348,7 @@ rac_result_t resolve(const std::string &ref, Resolved *out, std::string *error, // (glm-5.3-flash, ...) as a typo, and `wally run`/`llm generate` only // ever loads a model on this machine — there is no cloud fallback here // to dead-end into quietly. Point at the one that exists. - *error += " (try `wally list --all`, an hf.co/org/repo[:quant] ref, a " + *error += " (try `wally models list --all`, an hf.co/org/repo[:quant] ref, a " "direct URL, or a path to a local bundle directory — or, if " "it's a model your account has on the hosted console, `wally " "claude-code -m " + diff --git a/src/cli_formatter.cpp b/src/cli_formatter.cpp index 476672f6..cf2c83b1 100644 --- a/src/cli_formatter.cpp +++ b/src/cli_formatter.cpp @@ -1,7 +1,7 @@ #include "cli_formatter.h" #include -#include +#include #include #include #include @@ -32,31 +32,185 @@ Palette make_palette(bool enabled) { } // namespace cli_color +bool color_output_enabled(bool no_color_flag) { + if (no_color_flag) return false; + if (std::getenv("NO_COLOR") != nullptr) return false; +#ifdef _WIN32 + return _isatty(_fileno(stdout)) != 0; +#else + return isatty(fileno(stdout)) != 0; +#endif +} + namespace { +// Where an example's note starts: two spaces of indent plus the longest +// command the help carries, with a gap after it. +constexpr std::size_t kExampleNoteColumn = 48; + +// Shortest gap between a row's left column and its description. +constexpr std::size_t kColumnGap = 2; + +// Sentence-case headings for CLI11's upper-case defaults. Groups a command +// registers itself pass through unchanged. +std::string heading_for(const std::string& group) { + if (group == "OPTIONS") return "Options"; + if (group == "SUBCOMMANDS") return "Commands"; + return group; +} + +// CLI11 renders a `!--hide-thinking` flag as `--hide-thinking{false}`; the +// brace suffix is noise to a reader. +std::string strip_flag_default(const std::string& name) { + const std::size_t brace = name.find('{'); + return brace == std::string::npos ? name : name.substr(0, brace); +} + +// `TEXT:FILE` -> `FILE`, `TEXT:{on,off}` -> `{on,off}`, and a validator +// description such as `INT:INT in [1 - 2147483647]` -> `INT`. +std::string simplify_type_name(const std::string& type_name) { + const std::size_t colon = type_name.find(':'); + if (colon == std::string::npos) return type_name; + const std::string suffix = type_name.substr(colon + 1); + if (!suffix.empty() && suffix.front() == '{') return suffix; + const bool one_word = std::all_of(suffix.begin(), suffix.end(), [](unsigned char c) { + return std::isupper(c) != 0; + }); + return one_word && !suffix.empty() ? suffix : type_name.substr(0, colon); +} + +// Strips trailing whitespace from every line, collapses runs of blank lines to +// one, drops leading blank lines and ends with exactly one newline. +std::string tidy(const std::string& text) { + std::string out; + std::istringstream in(text); + std::string line; + bool previous_blank = true; // suppresses blank lines at the top + while (std::getline(in, line)) { + const std::size_t end = line.find_last_not_of(" \t\r"); + line = end == std::string::npos ? std::string() : line.substr(0, end + 1); + if (line.empty()) { + if (previous_blank) continue; + previous_blank = true; + } else { + previous_blank = false; + } + out += line; + out += '\n'; + } + while (out.size() >= 2 && out[out.size() - 1] == '\n' && out[out.size() - 2] == '\n') { + out.pop_back(); + } + return out; +} + +// Left column padded to the description column, or the description dropped to +// the next line when the left column is too wide to leave a gap. `left` is the +// plain text and is what the padding is measured from; `left_shown` is what is +// printed, and may carry color codes (setw would count those as visible +// characters and under-pad the description column). +void stream_row(std::stringstream& out, const std::string& left, const std::string& left_shown, + const std::string& desc, std::size_t column_width, std::size_t right_width) { + out << left_shown; + if (desc.empty()) { + out << '\n'; + return; + } + bool skip_first_line_prefix = true; + if (left.length() + kColumnGap <= column_width) { + out << std::string(column_width - left.length(), ' '); + } else { + out << '\n'; + skip_first_line_prefix = false; + } + CLI::detail::streamOutAsParagraph(out, desc, right_width, std::string(column_width, ' '), + skip_first_line_prefix); + out << '\n'; +} + std::string colorize(const std::string& text, const char* code, bool enabled) { if (!enabled || text.empty()) return text; return std::string(code) + text + cli_color::kResetCode; } +// A command that is listed as its children rather than as itself: a namespace +// like `models` or `account`, where "models pull" is the thing you type. +bool has_visible_children(const CLI::App* app) { + for (const CLI::App* child : app->get_subcommands({})) { + if (!child->get_name().empty() && !child->get_group().empty()) return true; + } + return false; +} + } // namespace -bool color_output_enabled(bool no_color_flag) { - if (no_color_flag) return false; - if (std::getenv("NO_COLOR") != nullptr) return false; -#ifdef _WIN32 - return _isatty(_fileno(stdout)) != 0; -#else - return isatty(fileno(stdout)) != 0; -#endif +std::string examples_footer(const std::vector& rows) { + std::string out = "Examples:"; + for (const Example& row : rows) { + std::string line = " " + row.command; + if (!row.note.empty()) { + line.append(line.size() + kColumnGap <= kExampleNoteColumn ? kExampleNoteColumn - line.size() + : kColumnGap, + ' '); + line += row.note; + } + out += '\n' + line; + } + return out; } -CliFormatter::CliFormatter(bool color_enabled) : color_enabled_(color_enabled) {} +CliFormatter::CliFormatter(bool color_enabled) : color_enabled_(color_enabled) { + label("POSITIONALS", "Arguments"); + label("SUBCOMMAND", "COMMAND"); + label("SUBCOMMANDS", "COMMANDS"); +} + +std::string CliFormatter::make_help(const CLI::App* app, std::string name, + CLI::AppFormatMode mode) const { + if (mode == CLI::AppFormatMode::Sub) { + // `--help-all` renders a child command through this Sub path (see + // make_subcommands below), which lands in CLI11's own make_expanded. + // That ends with make_footer(app), and make_footer is suppressed + // below so the *top-level* render can print the footer verbatim + // instead of reflowed as a paragraph -- so append it here too, or a + // child's Examples: block never shows up under --help-all. + std::string help = CLI::Formatter::make_help(app, name, mode); + const std::string footer = app->get_footer(); + if (!footer.empty()) { + help += '\n' + footer + '\n'; + } + return help; + } + std::stringstream out; + out << make_description(app); + out << make_usage(app, name); + out << make_positionals(app); + if (app->get_parent() == nullptr) { + // Root page: the commands are what someone came for; the global flags + // are the same on every page and go last. + out << make_subcommands(app, mode); + out << make_groups(app, mode); + } else { + out << make_groups(app, mode); + out << make_subcommands(app, mode); + } + const std::string footer = app->get_footer(); + if (!footer.empty()) { + out << '\n' << footer << '\n'; + } + return tidy(out.str()); +} + +std::string CliFormatter::make_usage(const CLI::App* app, std::string name) const { + std::string usage = CLI::Formatter::make_usage(app, name); + usage.erase(0, usage.find_first_not_of('\n')); + return "Usage: " + usage; +} std::string CliFormatter::make_group(std::string group, bool is_positional, std::vector opts) const { std::stringstream out; - out << "\n" << colorize(group, cli_color::kBoldCode, color_enabled_) << ":\n"; + out << "\n" << colorize(heading_for(group), cli_color::kBoldCode, color_enabled_) << ":\n"; for (const CLI::Option* opt : opts) { out << make_option(opt, is_positional); } @@ -67,8 +221,7 @@ std::string CliFormatter::make_subcommands(const CLI::App* app, CLI::AppFormatMo std::stringstream out; std::vector subcommands = app->get_subcommands({}); - // Make a list in definition order of the groups seen (mirrors - // CLI::Formatter::make_subcommands -- only the heading gets color here). + // Groups in definition order, as CLI::Formatter::make_subcommands does. std::vector subcmd_groups_seen; for (const CLI::App* com : subcommands) { if (com->get_name().empty()) { @@ -88,150 +241,86 @@ std::string CliFormatter::make_subcommands(const CLI::App* app, CLI::AppFormatMo } for (const std::string& group : subcmd_groups_seen) { - out << '\n' << colorize(group, cli_color::kBoldCode, color_enabled_) << ":\n"; + out << '\n' << colorize(heading_for(group), cli_color::kBoldCode, color_enabled_) << ":\n"; std::vector subcommands_group = app->get_subcommands([&group](const CLI::App* sub_app) { return CLI::detail::to_lower(sub_app->get_group()) == CLI::detail::to_lower(group); }); for (const CLI::App* new_com : subcommands_group) { if (new_com->get_name().empty()) continue; - if (mode != CLI::AppFormatMode::All) { - out << make_subcommand(new_com); - } else { + if (mode == CLI::AppFormatMode::All) { out << new_com->help(new_com->get_name(), CLI::AppFormatMode::Sub); out << '\n'; + continue; + } + // A namespace is listed as its children with the full path + // ("models pull"), so what the reader sees is what they type; the + // parent row alone is not a runnable command. A child hidden with an + // empty group (models load/unload/...) is left out, same as at the + // top. A leaf prints as itself. + if (has_visible_children(new_com)) { + for (const CLI::App* child : new_com->get_subcommands({})) { + if (child->get_name().empty() || child->get_group().empty()) continue; + out << make_subcommand_indented(child, " " + new_com->get_name() + " "); + } + } else { + out << make_subcommand_indented(new_com, " "); } } } - return out.str(); } -std::string CliFormatter::make_subcommand(const CLI::App* sub) const { +std::string CliFormatter::make_footer(const CLI::App* /*app*/) const { + return ""; +} + +std::string CliFormatter::make_subcommand_indented(const CLI::App* sub, const std::string& indent) const { std::stringstream out; - const std::string suffix = sub->get_required() ? " " + get_label("REQUIRED") : ""; - const std::string plain_name = " " + sub->get_display_name(true) + suffix; + // Primary name only (no ", alias" tail): the tree reads cleaner, and the + // aliases still resolve on the command line. + const std::string left = indent + sub->get_display_name(false); + stream_row(out, left, colorize(left, cli_color::kBoldCyanCode, color_enabled_), sub->get_description(), + get_column_width(), get_right_column_width()); + return out.str(); +} - out << colorize(" " + sub->get_display_name(true), cli_color::kBoldCyanCode, color_enabled_) << suffix; - if (plain_name.length() < get_column_width()) { - out << std::string(get_column_width() - plain_name.length(), ' '); +std::string CliFormatter::make_option_opts(const CLI::Option* opt) const { + std::string out; + if (opt->get_type_size() != 0) { + const std::string type = simplify_type_name(opt->get_type_name()); + if (!type.empty()) out += " " + type; + if (opt->get_expected_max() == CLI::detail::expected_max_vector_size) out += " ..."; + // CLI11's own Formatter::make_option_opts appends this too; drop it + // here and a required() option (e.g. -m, --model TEXT) reads as + // optional in --help. + if (opt->get_required()) out += " " + get_label("REQUIRED"); } - CLI::detail::streamOutAsParagraph(out, sub->get_description(), get_right_column_width(), - std::string(get_column_width(), ' '), true); - out << '\n'; - return out.str(); + return out; } std::string CliFormatter::make_option(const CLI::Option* opt, bool is_positional) const { std::stringstream out; - const std::size_t column_width = get_column_width(); - + std::string names; // the typeable part, colored + std::string opts; // value type and markers, plain if (is_positional) { - const std::string plain_left = " " + make_option_name(opt, true) + make_option_opts(opt); - const std::string desc = make_option_desc(opt); - - out << colorize(" " + make_option_name(opt, true), cli_color::kBoldCyanCode, color_enabled_) << make_option_opts(opt); - if (plain_left.length() < column_width) { - out << std::string(column_width - plain_left.length(), ' '); - } - - if (!desc.empty()) { - bool skip_first_line_prefix = true; - if (plain_left.length() >= column_width) { - out << '\n'; - skip_first_line_prefix = false; - } - CLI::detail::streamOutAsParagraph(out, desc, get_right_column_width(), std::string(column_width, ' '), - skip_first_line_prefix); - } - out << '\n'; - return out.str(); - } - - // Non-positional: same short-name / long-name column split as - // CLI::Formatter::make_option, reproduced here because coloring the name - // and padding it with std::setw don't mix -- setw counts the ANSI escape - // bytes as visible characters and under-pads the description column. - // `visible_length` tracks what setw would have measured on the plain - // (uncolored) text so the layout stays identical either way. - const std::string names_combined = make_option_name(opt, false); - const std::string opts_text = make_option_opts(opt); - const std::string desc = make_option_desc(opt); - - const auto names = CLI::detail::split(names_combined, ','); - std::vector short_names_v; - std::vector long_names_v; - std::for_each(names.begin(), names.end(), [&short_names_v, &long_names_v](const std::string& name) { - if (name.find("--", 0) != std::string::npos) - long_names_v.push_back(name); - else - short_names_v.push_back(name); - }); - - const std::string short_names = CLI::detail::join(short_names_v, ", "); - const std::string long_names = CLI::detail::join(long_names_v, ", "); - - const auto short_column_width = static_cast(column_width / 3); - const auto long_column_width = - static_cast(std::ceil(static_cast(column_width) / 3.0f * 2.0f)); - int short_over_size = 0; - std::size_t visible_length = 0; - - if (!short_names.empty()) { - std::string plain_short = " " + short_names; - if (long_names.empty() && !opts_text.empty()) plain_short += opts_text; - if (!long_names.empty()) plain_short += ","; - if (static_cast(plain_short.length()) >= short_column_width) { - plain_short += " "; - short_over_size = static_cast(plain_short.length()) - short_column_width; - } - - const std::string colored_name = colorize(" " + short_names, cli_color::kBoldCyanCode, color_enabled_); - const std::string trailer = plain_short.substr(2 + short_names.length()); - out << colored_name << trailer; - visible_length += plain_short.length(); - if (static_cast(plain_short.length()) < short_column_width) { - const std::size_t pad = static_cast(short_column_width) - plain_short.length(); - out << std::string(pad, ' '); - visible_length += pad; - } + // The usage line already says which arguments are required and which + // are repeatable, so a positional row is just its name. + names = " " + make_option_name(opt, true); } else { - out << std::string(static_cast(short_column_width), ' '); - visible_length += static_cast(short_column_width); - } - - short_over_size = (std::min)(short_over_size, long_column_width); - const auto adjusted_long_width = long_column_width - short_over_size; - - if (!long_names.empty()) { - std::string plain_long = long_names; - if (!opts_text.empty()) plain_long += opts_text; - if (static_cast(plain_long.length()) >= adjusted_long_width) plain_long += " "; - - const std::string colored_name = colorize(long_names, cli_color::kBoldCyanCode, color_enabled_); - const std::string trailer = plain_long.substr(long_names.length()); - out << colored_name << trailer; - visible_length += plain_long.length(); - if (static_cast(plain_long.length()) < adjusted_long_width) { - const std::size_t pad = static_cast(adjusted_long_width) - plain_long.length(); - out << std::string(pad, ' '); - visible_length += pad; + // "-m, --model TEXT". A long-only option sits under the long column so + // every long name lines up: " --json". + std::vector short_names; + std::vector long_names; + for (const std::string& name : CLI::detail::split(make_option_name(opt, false), ',')) { + (name.rfind("--", 0) == 0 ? long_names : short_names).push_back(strip_flag_default(name)); } - } else { - out << std::string(static_cast(adjusted_long_width), ' '); - visible_length += static_cast(adjusted_long_width); + names = short_names.empty() ? " " : " " + CLI::detail::join(short_names, ", "); + if (!short_names.empty() && !long_names.empty()) names += ", "; + names += CLI::detail::join(long_names, ", "); + opts = make_option_opts(opt); } - - if (!desc.empty()) { - bool skip_first_line_prefix = true; - if (visible_length > column_width) { - out << '\n'; - skip_first_line_prefix = false; - } - CLI::detail::streamOutAsParagraph(out, desc, get_right_column_width(), std::string(column_width, ' '), - skip_first_line_prefix); - } - - out << '\n'; + stream_row(out, names + opts, colorize(names, cli_color::kBoldCyanCode, color_enabled_) + opts, + make_option_desc(opt), get_column_width(), get_right_column_width()); return out.str(); } diff --git a/src/cli_formatter.h b/src/cli_formatter.h index 0b5d7173..087cde9b 100644 --- a/src/cli_formatter.h +++ b/src/cli_formatter.h @@ -1,7 +1,10 @@ /** * @file cli_formatter.h - * @brief Minimal, tasteful color formatter for `--help` (bold headings, - * bold+cyan command/option names, plain descriptions) -- think `gh`. + * @brief `--help` layout for wally: a `Usage:` line, sentence-case section + * headings, one `-m, --model TEXT` column for options, commands grouped by + * intent with namespaces shown as full paths (`models pull`), and a verbatim + * `Examples:` footer. On a terminal, headings are bold and anything typeable + * is cyan; anywhere else the text is plain and byte-identical. */ #ifndef WALLY_CLI_FORMATTER_H @@ -19,9 +22,8 @@ namespace wally { // redirected output (CI logs, `| cat`, a file) always gets plain text. bool color_output_enabled(bool no_color_flag); -// The same tasteful, minimal palette CliFormatter uses for `--help`, shared so -// other output (`wally about`) styles itself identically instead of picking -// its own ANSI codes. +// The palette `--help` and other output (`wally about`, the default-model +// notice) style themselves with, so every surface uses the same two colors. namespace cli_color { // Every field is "" when color is disabled, so a caller can always splice @@ -40,23 +42,48 @@ Palette make_palette(bool enabled); } // namespace cli_color -// Overrides just enough of CLI::Formatter to color section headings and -// command/option names, computing column padding from the visible (plain) -// text rather than the ANSI-decorated one -- CLI11's own setw-based padding -// would otherwise miscount escape bytes as visible characters and misalign -// the description column. Everything else (usage, footer, description -// wrapping) is untouched. +// One line of an `Examples:` block: the command, and an optional note that +// says what it does. +struct Example { + std::string command; + std::string note; +}; + +// The `Examples:` footer a command's help ends with. Commands sit at a +// two-space indent and every note starts at the same column, so the block +// reads as a table whichever command it is under. No trailing newline. +std::string examples_footer(const std::vector& rows); + +// Overrides CLI::Formatter to lay help out the way every wally command shows +// it: description, `Usage: wally ...`, Arguments, Options, Commands (with one +// level of nested subcommands), Examples. Padding is computed from plain text, +// trailing whitespace is stripped and blank lines are collapsed, so the output +// is the same whether it lands on a terminal or in a file. class CliFormatter : public CLI::Formatter { public: explicit CliFormatter(bool color_enabled); + std::string make_help(const CLI::App* app, std::string name, CLI::AppFormatMode mode) const override; + std::string make_usage(const CLI::App* app, std::string name) const override; CLI11_NODISCARD std::string make_group(std::string group, bool is_positional, std::vector opts) const override; std::string make_subcommands(const CLI::App* app, CLI::AppFormatMode mode) const override; - std::string make_subcommand(const CLI::App* sub) const override; + // The footer is printed verbatim by make_help; CLI11's own make_footer + // would reflow it as a paragraph and collapse the example columns. + std::string make_footer(const CLI::App* app) const override; std::string make_option(const CLI::Option* opt, bool is_positional) const override; + // Just the value type (`TEXT`, `INT`, `FILE`, `{on,off}`), without the + // validator text CLI11 appends (`INT:INT in [1 - 2147483647]`). + std::string make_option_opts(const CLI::Option* opt) const override; private: + // Renders one command row at a chosen left indent, so a parent prints at + // " " and its subcommands print nested beneath at a deeper indent. The + // description column is the same for every row regardless of indent, which + // is what keeps the two levels aligned. + std::string make_subcommand_indented(const CLI::App* sub, const std::string& indent) const; + + // Bold headings and cyan names when true; identical plain text when false. bool color_enabled_; }; diff --git a/src/commands/cmd_about.cpp b/src/commands/cmd_about.cpp index d13c9637..7f5d1c81 100644 --- a/src/commands/cmd_about.cpp +++ b/src/commands/cmd_about.cpp @@ -62,7 +62,7 @@ void heading(const cli_color::Palette& pal, const std::string& title) { void register_about(CLI::App& app, GlobalOptions& options) { CLI::App* cmd = - app.add_subcommand("about", "Detailed product, system and runtime report"); + app.add_subcommand("about", "Versions, backends, paths and account"); cmd->callback([&options]() { Bootstrapped env; if (bootstrap(options, &env) != RAC_SUCCESS) { @@ -81,7 +81,7 @@ void register_about(CLI::App& app, GlobalOptions& options) { adapter->get_memory_info(&memory, adapter->user_data) == RAC_SUCCESS; } - const std::map engines = collect_backend_rows(); + const std::map engines = collect_llm_backend_rows(); // Which bottle this binary is: WALLY_BAKED_CONSOLE_API_URL is compiled // in empty for a production build and non-empty for a dev one (see diff --git a/src/commands/cmd_account.cpp b/src/commands/cmd_account.cpp index 036a01da..1a2dcb38 100644 --- a/src/commands/cmd_account.cpp +++ b/src/commands/cmd_account.cpp @@ -21,6 +21,7 @@ #include "account/console.h" #include "account/credentials.h" #include "account/model_cache.h" +#include "cli_formatter.h" #include "commands/commands.h" #include "io/output.h" @@ -166,7 +167,7 @@ bool RefreshSession(const account::ConsoleClient& client, account::Credentials* std::string* error) { if (credentials->refresh_token.empty()) { if (error != nullptr) { - *error = "the cloud session cannot be refreshed; run `wally login`"; + *error = "the cloud session cannot be refreshed; run `wally account login`"; } return false; } @@ -334,7 +335,7 @@ int WhoAmI(bool as_json) { return 1; } if (!credentials.signed_in()) { - out::error_line("not signed in — run `wally login`"); + out::error_line("not signed in — run `wally account login`"); return 1; } @@ -388,24 +389,37 @@ int WhoAmI(bool as_json) { } // namespace void register_account(CLI::App& app, GlobalOptions& options) { + CLI::App* account_cmd = + app.add_subcommand("account", "Manage your RunAnywhere cloud account"); + account_cmd->require_subcommand(1); + auto no_browser = std::make_shared(false); - auto console_url = std::make_shared(); - auto* login = app.add_subcommand("login", "sign in through the RunAnywhere console"); - login->add_flag("--no-browser", *no_browser, "print the URL instead of opening it"); - login - ->add_option("--console-url", *console_url, - "console API origin (default: " + account::DefaultConsoleUrl() + ")") - ->envname("WALLY_CONSOLE_URL"); - login->callback([no_browser, console_url] { fail(Login(*console_url, !*no_browser)); }); + // The console origin is not a user-facing flag: it comes from the baked + // default, or WALLY_CONSOLE_URL for a dev build (read directly in + // credentials.cpp). Login() falls back to that when handed an empty string. + const std::string console_url; + + auto* login = account_cmd->add_subcommand("login", "Sign in through the browser"); + login->add_flag("--no-browser", *no_browser, "Print the sign-in URL instead of opening it"); + login->footer(examples_footer({ + {"wally account login", ""}, + {"wally account login --no-browser", ""}, + })); + login->callback([no_browser, console_url] { fail(Login(console_url, !*no_browser)); }); - auto* logout = app.add_subcommand("logout", "revoke and remove the cloud session"); + auto* logout = account_cmd->add_subcommand("logout", "Sign out and revoke the session"); + logout->footer(examples_footer({{"wally account logout", ""}})); logout->callback([] { fail(Logout()); }); auto whoami_json = std::make_shared(false); - auto* whoami = app.add_subcommand("whoami", "show the signed-in cloud account"); - whoami->add_flag("--json", *whoami_json, "machine-readable output"); - // `wally --json whoami` and `wally whoami --json` mean the same thing; see - // the identical fix in register_usage (cmd_usage.cpp). + auto* whoami = account_cmd->add_subcommand("whoami", "Show the signed-in account"); + whoami->add_flag("--json", *whoami_json, "Print as JSON"); + whoami->footer(examples_footer({ + {"wally account whoami", ""}, + {"wally --json account whoami", ""}, + })); + // `wally --json account whoami` and `... whoami --json` mean the same thing; + // see the identical fix in register_usage (cmd_usage.cpp). whoami->callback([whoami_json, &options] { fail(WhoAmI(*whoami_json || options.json)); }); } diff --git a/src/commands/cmd_backends.cpp b/src/commands/cmd_backends.cpp index d7d87f98..d8e4d29e 100644 --- a/src/commands/cmd_backends.cpp +++ b/src/commands/cmd_backends.cpp @@ -5,6 +5,7 @@ #include "commands/commands.h" +#include #include #include #include @@ -48,6 +49,15 @@ std::map collect_backend_rows() { return engines; } +std::map collect_llm_backend_rows() { + std::map engines = collect_backend_rows(); + const std::string llm = rac_primitive_name(RAC_PRIMITIVE_GENERATE_TEXT); + for (auto it = engines.begin(); it != engines.end();) { + it = it->second.primitives.count(llm) ? std::next(it) : engines.erase(it); + } + return engines; +} + void register_backends(CLI::App& app, GlobalOptions& options) { CLI::App* cmd = app.add_subcommand("backends", "List registered inference backends"); cmd->callback([&options]() { diff --git a/src/commands/cmd_bench.cpp b/src/commands/cmd_bench.cpp index 544b76f7..fd3d116c 100644 --- a/src/commands/cmd_bench.cpp +++ b/src/commands/cmd_bench.cpp @@ -673,7 +673,7 @@ int run_bench(const GlobalOptions& options, const std::string& model_ref_arg, in } if (models.empty()) { out::error_line(only_model.empty() - ? "no downloaded models to benchmark (pull one with `wally pull`)" + ? "no downloaded models to benchmark (pull one with `wally models pull`)" : "model '" + only_model + "' is not a downloaded benchmarkable model"); return 1; } @@ -790,7 +790,7 @@ void register_bench(CLI::App& app, GlobalOptions& options) { "Model id, local bundle path, hf.co/... or URL (default: all " "downloaded)"); cmd->add_option("--engine", *engine, - "Engine hint (neurt|coreml|ane, mlx, llamacpp, onnx, sherpa)"); + std::string("Engine hint (") + engine_choices() + ")"); cmd->add_option("--trials,-n", *trials, "Measured trials per scenario (median reported)") ->default_val(3) // Range, not PositiveNumber, for the message alone. PositiveNumber diff --git a/src/commands/cmd_default_models.cpp b/src/commands/cmd_default_models.cpp index c23f1a75..88011e25 100644 --- a/src/commands/cmd_default_models.cpp +++ b/src/commands/cmd_default_models.cpp @@ -34,10 +34,28 @@ void register_default_models(CLI::App& app, GlobalOptions& options) { auto model = std::make_shared(); auto clear = std::make_shared(false); - CLI::App* cmd = app.add_subcommand( - "default-models", "Set the model a harness uses when you pass no -m"); - cmd->add_option("model", *model, "a model id to make the default, e.g. glm-5.3-flash"); - cmd->add_flag("--clear", *clear, "remove the saved default"); + // Lives under `models` (`wally models default`). register_models runs first + // (app.cpp), so the namespace exists; a reorder would trip OptionNotFound. + // Registering here (rather than after app.cpp's group-normalizing loop) + // also matters for --help: `default` inherits the same default group as + // `list`/`show`/`pull`/`rm` only because it is added before that loop + // runs, the same way app.cpp guards its own get_subcommand(hidden) + // lookups: a startup crash from a future reorder is worse than silently + // skipping this subcommand. + CLI::App* models = nullptr; + try { + models = app.get_subcommand("models"); + } catch (const CLI::OptionNotFound&) { + return; + } + CLI::App* cmd = + models->add_subcommand("default", "Show or set the default model for coding tools"); + cmd->add_option("model", *model, "Model id to save as the default (omit to show it)"); + cmd->add_flag("--clear", *clear, "Forget the saved default"); + cmd->footer(examples_footer({ + {"wally models default glm-5.3-flash", ""}, + {"wally models default --clear", ""}, + })); cmd->callback([model, clear] { if (*clear) { diff --git a/src/commands/cmd_editors.cpp b/src/commands/cmd_editors.cpp index 1559a466..d6b73976 100644 --- a/src/commands/cmd_editors.cpp +++ b/src/commands/cmd_editors.cpp @@ -14,6 +14,7 @@ #include #include "anthropic/messages.h" +#include "cli_formatter.h" #include "commands/editor_env.h" #include "commands/commands.h" #include "config/cli_paths.h" @@ -67,8 +68,8 @@ struct Editor { /// to the Claude Code it runs inside itself, and ANTHROPIC_BASE_URL is one of /// them. That is the same trick as `wally claude-code`, one process further out. constexpr Editor kEditors[] = { - {"claude-code", "claude", "", "open Claude Code against a model", Wiring::Environment}, - {"claude-desktop", "", "Claude.app", "open Claude Desktop against a model", + {"claude-code", "claude", "", "Open Claude Code with a model", Wiring::Environment}, + {"claude-desktop", "", "Claude.app", "Open Claude Desktop with a model", Wiring::ClaudeProfile}, }; @@ -547,19 +548,24 @@ void register_editors(CLI::App& app, GlobalOptions& options) { auto rest = std::make_shared>(); auto serve = std::make_shared(false); auto* command = app.add_subcommand(editor.id, editor.summary); + const std::string invocation = "wally " + std::string(editor.id); + command->footer(examples_footer({ + {invocation + " -m qwen3-0.6b", "A model on this machine"}, + {invocation + " -m glm-5.3-flash", "A hosted model (needs `wally account login`)"}, + })); command->add_option("-m,--model", *model, - "a model on this machine, or one served upstream"); + "A model on this machine, or a hosted one from your account"); command->add_flag("--serve", *serve, - "hold the endpoint open and print it, instead of launching"); + "Print the endpoint and keep it open instead of launching"); if (editor.wiring == Wiring::ClaudeProfile) { command->add_flag("--restore", *restore, - "undo what we configured and launch nothing"); + "Put Claude Desktop back on Anthropic and exit"); } // Tokens after the wally flags belong to the tool, its own flags // included. They reach here as positionals because `run()` inserts a // `--` ahead of them (see SplitPassthroughArgv); CLI11 would otherwise // read a leading `--flag` as an unknown wally option and reject it. - command->add_option("args", *rest, "passed through to the tool")->allow_extra_args(); + command->add_option("args", *rest, "Passed through to the tool")->allow_extra_args(); command->prefix_command(); command->callback([&options, &editor, model, rest, serve, restore] { if (*restore) { diff --git a/src/commands/cmd_embed.cpp b/src/commands/cmd_embed.cpp index 456f0d42..ce5448f1 100644 --- a/src/commands/cmd_embed.cpp +++ b/src/commands/cmd_embed.cpp @@ -276,9 +276,9 @@ void register_embed(CLI::App& app, GlobalOptions& options) { "Embedding model to use (default: " + std::string(kDefaultEmbeddingModel) + ")") ->default_val(kDefaultEmbeddingModel); cmd->add_option("--engine", *engine, - "Engine hint (neurt|coreml|ane, mlx, llamacpp, onnx, sherpa, qhexrt). " - "Honoured for catalog models too, not just URL/HF refs. Omit to let " - "catalog framework / plugin priority pick."); + std::string("Engine hint (") + engine_choices() + + "). Honoured for catalog models too, not just URL/HF refs. Omit to " + "let catalog framework / plugin priority pick."); cmd->add_option("--text,-t", *option_texts, "Embed this text too; repeat to batch several"); cmd->add_option("--normalize", *normalize, "Scale vectors to unit length or leave them raw") diff --git a/src/commands/cmd_harness.cpp b/src/commands/cmd_harness.cpp index d6480f49..18fe5b71 100644 --- a/src/commands/cmd_harness.cpp +++ b/src/commands/cmd_harness.cpp @@ -3,6 +3,7 @@ #include #include "catalog/catalog.h" +#include "cli_formatter.h" #include "commands/commands.h" #include "io/output.h" #include "harness/harness.h" @@ -27,15 +28,19 @@ void register_harness(CLI::App& app, GlobalOptions& options) { auto model = std::make_shared(); auto rest = std::make_shared>(); auto cloud = std::make_shared(false); - auto* opencode = - app.add_subcommand("opencode", "open a coding session in opencode, wired to a model"); + auto* opencode = app.add_subcommand("opencode", "Open opencode with a model"); + opencode->footer(examples_footer({ + {"wally opencode -m qwen3-0.6b", "A model on this machine"}, + {"wally opencode --cloud -m glm-5.3-flash", "A hosted model (needs `wally account login`)"}, + })); // A named option rather than a positional: with two positionals there is no // way to tell `wally opencode run` asking for passthrough from someone // naming a model called run, and the first reading wins silently. - opencode->add_option("-m,--model", *model, "a model on this machine, or one served upstream"); + opencode->add_option("-m,--model", *model, + "A model on this machine, or a hosted one from your account"); opencode->add_flag("--cloud", *cloud, - "use the signed-in hosted endpoint (never routes local models)"); - opencode->add_option("args", *rest, "passed through to opencode")->allow_extra_args(); + "Use your account's hosted endpoint (never a local model)"); + opencode->add_option("args", *rest, "Passed through to opencode")->allow_extra_args(); opencode->prefix_command(); opencode->callback([&options, model, rest, cloud] { // Before resolving a model or printing anything: a person without the @@ -49,7 +54,7 @@ void register_harness(CLI::App& app, GlobalOptions& options) { if (effective.empty()) { out::error_line( "--cloud requires --model , and no default is set " - "(wally default-models )"); + "(wally models default )"); fail(2); } fail(harness::LaunchOpenCodeCloud(effective, *rest)); @@ -66,9 +71,14 @@ void register_harness(CLI::App& app, GlobalOptions& options) { auto agent_model = std::make_shared(); auto agent_rest = std::make_shared>(); auto* command = app.add_subcommand(agent.id, agent.summary); + const std::string invocation = "wally " + std::string(agent.id); + command->footer(examples_footer({ + {invocation + " -m qwen3-0.6b", "A model on this machine"}, + {invocation + " -m glm-5.3-flash", "A hosted model (needs `wally account login`)"}, + })); command->add_option("-m,--model", *agent_model, - "a model on this machine, or one served upstream"); - command->add_option("args", *agent_rest, "passed through to the tool") + "A model on this machine, or a hosted one from your account"); + command->add_option("args", *agent_rest, "Passed through to the tool") ->allow_extra_args(); command->prefix_command(); command->callback([&options, &agent, agent_model, agent_rest] { diff --git a/src/commands/cmd_info.cpp b/src/commands/cmd_info.cpp index 8e8ebfe3..2a9de6b0 100644 --- a/src/commands/cmd_info.cpp +++ b/src/commands/cmd_info.cpp @@ -9,7 +9,6 @@ #include "rac/core/rac_core.h" #include "rac/core/rac_platform_adapter.h" -#include "rac/plugin/rac_plugin_entry.h" #include "config/cli_paths.h" #include "io/output.h" @@ -21,8 +20,7 @@ namespace wally::commands { void register_info(CLI::App& app, GlobalOptions& options) { - CLI::App* cmd = app.add_subcommand("info", "Report versions, paths, memory and backends"); - cmd->alias("doctor"); + CLI::App* cmd = app.add_subcommand("info", "Show versions, paths, memory and backends"); cmd->callback([&options]() { Bootstrapped env; if (bootstrap(options, &env) != RAC_SUCCESS) { @@ -49,6 +47,8 @@ void register_info(CLI::App& app, GlobalOptions& options) { const char* platform = "unknown"; #endif + const auto backends = static_cast(collect_llm_backend_rows().size()); + if (options.json) { out::JsonWriter json; json.begin_object() @@ -58,7 +58,7 @@ void register_info(CLI::App& app, GlobalOptions& options) { .field("home", env.home) .field("models_dir", env.models_dir) .field("state_dir", paths::state_dir()) - .field("backends", static_cast(rac_plugin_count())); + .field("backends", backends); if (memory_ok) { json.field("memory_total_bytes", static_cast(memory.total_bytes)) .field("memory_available_bytes", @@ -69,15 +69,22 @@ void register_info(CLI::App& app, GlobalOptions& options) { return; } - out::result_line("wally " WALLY_VERSION); - out::result_line("commons " + commons_version); - out::result_line("platform " + std::string(platform)); - out::result_line("home " + env.home); - out::result_line("models " + env.models_dir); - out::result_line("backends " + std::to_string(rac_plugin_count())); + // One column for the labels so every value starts at the same offset. + // The widest label is "platform"/"backends" (8); pad to 10. + auto row = [](const char* label, const std::string& value) { + std::string key(label); + if (key.size() < 10) key.append(10 - key.size(), ' '); + out::result_line(key + value); + }; + row("wally", WALLY_VERSION); + row("commons", commons_version); + row("platform", platform); + row("home", env.home); + row("models", env.models_dir); + row("backends", std::to_string(backends)); if (memory_ok) { - out::result_line("memory " + out::human_bytes(memory.available_bytes) + - " available of " + out::human_bytes(memory.total_bytes)); + row("memory", out::human_bytes(memory.available_bytes) + " available of " + + out::human_bytes(memory.total_bytes)); } }); } diff --git a/src/commands/cmd_list.cpp b/src/commands/cmd_list.cpp index e0199004..2f6ca33b 100644 --- a/src/commands/cmd_list.cpp +++ b/src/commands/cmd_list.cpp @@ -1,6 +1,6 @@ /** * @file cmd_list.cpp - * @brief `wally models list` (alias `wally list`) — downloaded models by + * @brief `wally models list` (alias `wally models ls`) — downloaded models by * default, the whole catalog with --all. * * The registry is refreshed with rescan_local so on-disk artifacts pulled by @@ -10,15 +10,21 @@ #include "commands/commands.h" +#include +#include +#include #include #include #include +#include +#include #include #include "model_types.pb.h" #include "rac/core/rac_core.h" #include "rac/infrastructure/model_management/rac_model_registry.h" +#include "catalog/catalog.h" #include "commands/model_setup.h" #include "commands/model_labels.h" #include "io/output.h" @@ -30,6 +36,56 @@ namespace { namespace v1 = runanywhere::v1; +// The same model is registered once per backend it runs on (llama.cpp / MLX / +// ANE / NPU). `models list` collapses those into one row keyed by the catalog's +// merge_key, joining the backends into "mlx/llama.cpp"-style tags. Lower rank = +// listed first in the joined tag and preferred for the row's name/size. +int backend_rank(v1::InferenceFramework framework) { + switch (framework) { + case v1::INFERENCE_FRAMEWORK_MLX: return 0; + case v1::INFERENCE_FRAMEWORK_LLAMA_CPP: return 1; + case v1::INFERENCE_FRAMEWORK_COREML: return 2; + case v1::INFERENCE_FRAMEWORK_QHEXRT: return 3; + default: return 4; + } +} + +struct GroupedRow { + std::string id; // merge key by default; see the override below + std::string size_id; // id of the variant that set size_bytes + std::string local_path; // local_path of the variant backing `id`, if downloaded + std::string name; + v1::ModelCategory category = v1::MODEL_CATEGORY_UNSPECIFIED; + int64_t size_bytes = 0; + int name_rank = INT_MAX; // rank of the variant that set name/category + int size_rank = INT_MAX; // rank of the variant that set a positive size + // Rank of the downloaded variant currently backing `id`/`local_path` + // (INT_MAX = none downloaded yet). The merge key is always a real, listed + // catalog id (today, the llama.cpp variant's own), so it is a fine default + // for a row nothing has been downloaded for. But once some other backend + // is the one actually on disk, printing the bare merge key left + // `models show/rm/pull` silently resolving to that other, undownloaded + // variant instead — so a downloaded variant's own id always wins here. + int id_rank = INT_MAX; + bool downloaded = false; + // Distinct backends, ordered by (rank, label) so the join is stable. + std::set> backends; +}; + +// A short "how do I download one?" header for the human list. The pull id +// differs by backend, so show one example per backend this build can run: +// llama.cpp everywhere; on Apple also MLX. Never printed in --json. +void print_pull_examples() { + out::result_line("Download a model with `wally models pull `:"); + out::result_line(" wally models pull qwen3-0.6b # llama.cpp"); +#if defined(__APPLE__) + out::result_line(" wally models pull mlx-qwen3-0.6b # MLX (Apple GPU)"); +#endif + // TEMP(ane-cut): no ANE rows in the catalog, so nothing to point at. + // out::result_line(" wally models pull ane-lfm2.5-350m # ANE (Apple Neural Engine)"); + out::result_line(""); +} + int run_list(const GlobalOptions& options, bool show_all) { Bootstrapped env; if (bootstrap(options, &env) != RAC_SUCCESS) { @@ -65,24 +121,84 @@ int run_list(const GlobalOptions& options, bool show_all) { } } + // Collapse per-backend variants of the same model into one row, grouped by + // the catalog merge_key (a non-catalog id groups with itself). `row.id` + // starts as that merge key but can be displaced — see `GroupedRow::id_rank`. + // Insertion order is kept so the list reads the same as the registry. + std::vector order; + std::unordered_map groups; + for (const v1::ModelInfo& model : all_models.models()) { + const bool is_downloaded = + downloaded_ids.count(model.id()) > 0 || + model.registry_status() == v1::MODEL_REGISTRY_STATUS_DOWNLOADED; + if (!show_all && !is_downloaded) { + continue; + } + // LLM-only surface: a downloaded non-LLM model restored from a manifest + // must not reappear in the list. + if (model.category() != v1::MODEL_CATEGORY_LANGUAGE) { + continue; + } + const std::string key = catalog::merge_key_for(model.id()); + auto it = groups.find(key); + if (it == groups.end()) { + GroupedRow row; + row.id = key; + it = groups.emplace(key, std::move(row)).first; + order.push_back(key); + } + GroupedRow& row = it->second; + const int rank = backend_rank(model.framework()); + row.backends.insert({rank, model_labels::short_backend(model.framework())}); + row.downloaded = row.downloaded || is_downloaded; + // A downloaded variant's own id/local_path always displaces the merge + // key default, best rank first among downloaded variants. + if (is_downloaded && rank < row.id_rank) { + row.id_rank = rank; + row.id = model.id(); + row.local_path = model.local_path(); + } + if (rank < row.name_rank) { + row.name_rank = rank; + row.name = model.name(); + row.category = model.category(); + } + const int64_t size = static_cast(model.download_size_bytes()); + if (size > 0 && rank < row.size_rank) { + row.size_rank = rank; + row.size_bytes = size; + row.size_id = model.id(); + } + } + + auto join_backends = [](const GroupedRow& row) { + std::string joined; + for (const auto& [rank, label] : row.backends) { + (void)rank; + if (!joined.empty()) { + joined += "/"; + } + joined += label; + } + return joined; + }; + if (options.json) { out::JsonWriter json; json.begin_object().begin_array("models"); - for (const v1::ModelInfo& model : all_models.models()) { - const bool is_downloaded = - downloaded_ids.count(model.id()) > 0 || - model.registry_status() == v1::MODEL_REGISTRY_STATUS_DOWNLOADED; - if (!show_all && !is_downloaded) { - continue; - } + for (const std::string& key : order) { + const GroupedRow& row = groups.at(key); json.begin_array_object() - .field("id", model.id()) - .field("name", model.name()) - .field("modality", model_labels::category(model.category())) - .field("backend", model_labels::backend(model.framework())) - .field("size_bytes", static_cast(model.download_size_bytes())) - .field("downloaded", is_downloaded) - .field("local_path", model.local_path()) + .field("id", row.id) + .field("name", row.name) + .field("modality", model_labels::category(row.category)) + .field("backend", join_backends(row)) + .field("size_bytes", row.size_bytes) + .field("downloaded", row.downloaded) + // Path of the variant `id` refers to; empty when nothing in + // the group is downloaded (mirrors the pre-merge shape, which + // callers already treat "" as "not downloaded"). + .field("local_path", row.local_path) .end_object(); } json.end_array().end_object(); @@ -90,26 +206,23 @@ int run_list(const GlobalOptions& options, bool show_all) { return 0; } + print_pull_examples(); + std::vector> rows; - for (const v1::ModelInfo& model : all_models.models()) { - const bool is_downloaded = - downloaded_ids.count(model.id()) > 0 || - model.registry_status() == v1::MODEL_REGISTRY_STATUS_DOWNLOADED; - if (!show_all && !is_downloaded) { - continue; - } - rows.push_back({model.id(), model_labels::category(model.category()), - model_labels::backend(model.framework()), - model.download_size_bytes() > 0 - ? out::human_bytes(static_cast(model.download_size_bytes())) + for (const std::string& key : order) { + const GroupedRow& row = groups.at(key); + rows.push_back({row.id, model_labels::category(row.category), + join_backends(row), + row.size_bytes > 0 + ? out::human_bytes(static_cast(row.size_bytes)) : "-", - is_downloaded ? "yes" : "no"}); + row.downloaded ? "yes" : "no"}); } if (rows.empty()) { out::result_line(show_all ? "no models registered" - : "no models downloaded — try `wally list --all` then " - "`wally pull `"); + : "no models downloaded — try `wally models list --all` then " + "`wally models pull `"); return 0; } out::table({"ID", "MODALITY", "BACKEND", "SIZE", "DOWNLOADED"}, rows); @@ -120,7 +233,7 @@ int run_list(const GlobalOptions& options, bool show_all) { void configure_models_list(CLI::App* cmd, GlobalOptions& options) { auto show_all = std::make_shared(false); - cmd->add_flag("--all,-a", *show_all, "Include catalog models that are not downloaded"); + cmd->add_flag("--all,-a", *show_all, "Include catalog models not yet downloaded"); cmd->callback([&options, show_all]() { const int exit_code = run_list(options, *show_all); if (exit_code != 0) { diff --git a/src/commands/cmd_maintenance.cpp b/src/commands/cmd_maintenance.cpp index f43e26c6..bd9c697a 100644 --- a/src/commands/cmd_maintenance.cpp +++ b/src/commands/cmd_maintenance.cpp @@ -143,6 +143,11 @@ struct Target { fs::path path; }; +} // namespace + +// External (not in the anonymous namespace above) so the top-level +// `--uninstall` flag can call it too, via commands.h. Still uses the internal +// helpers above -- anonymous-namespace names stay visible through the TU. int run_uninstall(bool yes) { std::vector targets; @@ -236,17 +241,15 @@ int run_uninstall(bool yes) { return 0; } -} // namespace - void register_help(CLI::App& app, GlobalOptions& options) { static_cast(options); - CLI::App* cmd = app.add_subcommand("help", "Show help (same as --help)"); + CLI::App* cmd = app.add_subcommand("help", "Show help for a command"); auto topic = std::make_shared(); - cmd->add_option("command", *topic, "show help for this command"); + cmd->add_option("command", *topic, "Command to describe"); cmd->callback([&app, topic] { if (!topic->empty()) { try { - std::fputs(app.get_subcommand(*topic)->help().c_str(), stdout); + std::fputs(app.get_subcommand(*topic)->help(app.get_name()).c_str(), stdout); return; } catch (const CLI::Error&) { // No such subcommand: fall through to the top-level help. @@ -259,10 +262,9 @@ void register_help(CLI::App& app, GlobalOptions& options) { void register_uninstall(CLI::App& app, GlobalOptions& options) { static_cast(options); auto yes = std::make_shared(false); - CLI::App* cmd = app.add_subcommand( - "uninstall", - "Remove wally, its on-device models, and its config (leaves your coding tools)"); - cmd->add_flag("-y,--yes", *yes, "Delete without asking for confirmation"); + CLI::App* cmd = + app.add_subcommand("uninstall", "Remove wally, its models and its config"); + cmd->add_flag("-y,--yes", *yes, "Skip the confirmation prompt"); cmd->callback([yes] { const int code = run_uninstall(*yes); if (code != 0) { diff --git a/src/commands/cmd_models.cpp b/src/commands/cmd_models.cpp index 851a76b3..6337e671 100644 --- a/src/commands/cmd_models.cpp +++ b/src/commands/cmd_models.cpp @@ -28,6 +28,7 @@ #include "rac/infrastructure/model_management/rac_model_registry.h" #include "catalog/model_ref.h" +#include "cli_formatter.h" #include "commands/engine_options.h" #include "commands/model_labels.h" #include "io/output.h" @@ -346,19 +347,39 @@ int run_state(const GlobalOptions& options) { } // namespace void register_models(CLI::App& app, GlobalOptions& options) { - CLI::App* ns = app.add_subcommand("models", "Manage the local model catalog"); + CLI::App* ns = app.add_subcommand("models", "Manage local models"); ns->require_subcommand(1); - configure_models_list(ns->add_subcommand("list", "List models, downloaded ones by default"), - options); - configure_models_get(ns->add_subcommand("get", "Show one model's registry entry"), options); - configure_models_download( - ns->add_subcommand("download", "Fetch a model with resumable progress"), options); - configure_models_delete(ns->add_subcommand("delete", "Remove a model's files and registration"), - options); - + CLI::App* list_cmd = + ns->add_subcommand("list", "List downloaded models (--all for the catalog)"); + list_cmd->alias("ls"); + list_cmd->footer(examples_footer({{"wally models list --all", "Browse the whole catalog"}})); + configure_models_list(list_cmd, options); + + CLI::App* show_cmd = ns->add_subcommand("show", "Show a model's details"); + show_cmd->alias("get"); + show_cmd->footer(examples_footer({{"wally models show granite-4.2-8b", ""}})); + configure_models_get(show_cmd, options); + + CLI::App* pull_cmd = ns->add_subcommand("pull", "Download a model"); + pull_cmd->alias("download"); + pull_cmd->footer(examples_footer({ + {"wally models pull qwen3-0.6b", "From the catalog"}, + {"wally models pull hf.co///", "From Hugging Face"}, + })); + configure_models_download(pull_cmd, options); + + CLI::App* delete_cmd = ns->add_subcommand("rm", "Delete a downloaded model"); + delete_cmd->footer(examples_footer({{"wally models rm qwen3-0.6b", ""}})); + delete_cmd->alias("remove"); + delete_cmd->alias("delete"); + configure_models_delete(delete_cmd, options); + + // Advanced lifecycle verbs stay callable but out of the --help tree (empty + // group), so `models` shows just the four CRUD branches. CLI::App* register_cmd = ns->add_subcommand("register", "Add a model from a URL or hf.co ref to the registry"); + register_cmd->group(""); auto register_ref = std::make_shared(); auto register_engine = std::make_shared(); register_cmd->add_option("model", *register_ref, "hf.co/org/repo/file, hf:// or http(s) URL") @@ -373,6 +394,7 @@ void register_models(CLI::App& app, GlobalOptions& options) { }); CLI::App* load_cmd = ns->add_subcommand("load", "Load a model now instead of on first use"); + load_cmd->group(""); auto load_ref = std::make_shared(); auto load_engine = std::make_shared(); auto load_category = std::make_shared(); @@ -390,6 +412,7 @@ void register_models(CLI::App& app, GlobalOptions& options) { CLI::App* unload_cmd = ns->add_subcommand("unload", "Free loaded models, all of them by default"); + unload_cmd->group(""); auto unload_category = std::make_shared(); unload_cmd->add_option("category", *unload_category, "Only free this modality (" + category_choices() + ")"); @@ -401,6 +424,7 @@ void register_models(CLI::App& app, GlobalOptions& options) { }); CLI::App* state_cmd = ns->add_subcommand("state", "Report resident models and disk usage"); + state_cmd->group(""); state_cmd->callback([&options]() { const int exit_code = run_state(options); if (exit_code != 0) { diff --git a/src/commands/cmd_pull.cpp b/src/commands/cmd_pull.cpp index 8e66afe5..7612dea3 100644 --- a/src/commands/cmd_pull.cpp +++ b/src/commands/cmd_pull.cpp @@ -1,8 +1,8 @@ /** * @file cmd_pull.cpp - * @brief `wally models download ` (alias `wally pull`) — - * download via the commons orchestrator: plan → start → progress - * callback → terminal state. + * @brief `wally models pull ` (alias `wally models + * download`) — download via the commons orchestrator: plan → start → + * progress callback → terminal state. * * SIGINT cancels the task (partial bytes preserved → re-pull resumes via the * plan's can_resume path). Exit codes: 0 done, 1 failure, 130 user cancel. @@ -86,7 +86,7 @@ int pull_model_flow(const GlobalOptions &options, const std::string &model_id) { // bootstrap() registers the catalog; it does not rescan what is on disk. So // registry_status() below can still read DOWNLOADED for a model whose files - // were deleted since, and `wally pull` would report success without fetching + // were deleted since, and `wally models pull` would report success without fetching // anything. A refresh failure is not fatal here: the download path that // follows is the fallback, and refusing to pull because a rescan failed would // be worse than pulling something already present. @@ -298,8 +298,7 @@ void configure_models_download(CLI::App *cmd, GlobalOptions &options) { cmd->add_option("model", *ref, "Model id, alias, hf.co/org/repo/file or URL") ->required(); cmd->add_option("--engine", *engine, - "Engine hint (neurt|coreml|ane, mlx, llamacpp, onnx, sherpa). Honoured for " - "catalog models too, not just URL/HF refs."); + std::string("Engine to fetch for (") + engine_choices() + ")"); cmd->callback([&options, ref, engine]() { Bootstrapped env; if (bootstrap(options, &env) != RAC_SUCCESS) { diff --git a/src/commands/cmd_rag.cpp b/src/commands/cmd_rag.cpp index 01f6d456..29f73434 100644 --- a/src/commands/cmd_rag.cpp +++ b/src/commands/cmd_rag.cpp @@ -53,7 +53,7 @@ namespace { namespace v1 = runanywhere::v1; -constexpr const char* kDefaultRagLlm = "smollm2-360m-q8_0"; +constexpr const char* kDefaultRagLlm = "smollm2-135m"; constexpr const char* kDefaultRagEmbed = "all-minilm-l6-v2"; bool read_text_file(const std::string& path, std::string* out, std::string* error) { diff --git a/src/commands/cmd_rm.cpp b/src/commands/cmd_rm.cpp index 485ba514..d1d15485 100644 --- a/src/commands/cmd_rm.cpp +++ b/src/commands/cmd_rm.cpp @@ -1,7 +1,7 @@ /** * @file cmd_rm.cpp - * @brief `wally models delete ` (alias `wally rm`) — delete downloaded - * files + unregister. + * @brief `wally models rm ` (alias `wally models delete`/`wally models + * remove`) — delete downloaded files + unregister. * * File deletion is CLI-owned (registry remove only unregisters, per the * rac_model_registry_remove contract). Deletion targets come from the @@ -176,7 +176,7 @@ void configure_models_delete(CLI::App *cmd, GlobalOptions &options) { auto ref = std::make_shared(); auto force = std::make_shared(false); cmd->add_option("model", *ref, "Model id or alias")->required(); - cmd->add_flag("-f,--force", *force, "Do not ask for confirmation"); + cmd->add_flag("-f,--force", *force, "Skip the confirmation prompt"); cmd->callback([&options, ref, force]() { const int exit_code = run_rm(options, *ref, *force); if (exit_code != 0) { diff --git a/src/commands/cmd_run.cpp b/src/commands/cmd_run.cpp index f1c1c98e..98305427 100644 --- a/src/commands/cmd_run.cpp +++ b/src/commands/cmd_run.cpp @@ -43,6 +43,7 @@ #include "vlm_options.pb.h" #include "catalog/model_ref.h" +#include "cli_formatter.h" #include "commands/engine_options.h" #include "config/cli_paths.h" #include "io/output.h" @@ -684,51 +685,60 @@ void add_generation_options(CLI::App* cmd, const std::shared_ptr& par ModelArg model_arg, bool vlm) { (void)vlm; if (model_arg == ModelArg::Option) { - cmd->add_option("--model,-m", params->model, - "Model to generate with; downloads and loads it when absent"); + cmd->add_option("--model,-m", params->model, "Model to use (downloaded if missing)"); } else { cmd->add_option("model", params->model, "Model id, alias, hf.co/... ref or URL") ->required(); } - cmd->add_option("--system-prompt,--system", params->system_prompt, - "Steer the model with a system instruction"); - cmd->add_option("--lora", params->lora, - "Attach a LoRA adapter (.gguf) before generating"); - cmd->add_option("--lora-scale", params->lora_scale, - "How strongly the LoRA applies (default 1.0)"); + cmd->add_option("--system-prompt,--system", params->system_prompt, "System prompt"); + cmd->add_option("--lora", params->lora, "LoRA adapter (.gguf) to attach"); + cmd->add_option("--lora-scale", params->lora_scale, "LoRA strength (default 1.0)"); cmd->add_option("--engine", params->engine, - "Engine hint (neurt|coreml|ane, mlx, llamacpp, onnx, sherpa, qhexrt). " - "Honoured for catalog models too, not just URL/HF refs. Omit to let " - "catalog framework / plugin priority pick."); + std::string("Engine to run on (") + engine_choices() + ")"); + // The sampling knobs and the thinking switches get their own headings in + // --help, so the page reads as three short lists instead of one of twenty. + // Group names are plain strings CLI11 prints in first-seen order, so + // "Options" (the rows above) comes first, then these two. + const char* kSampling = "Sampling"; + const char* kReasoning = "Reasoning"; cmd->add_option("--temperature,--temp", params->temperature, - "Raise for more random sampling (0 = engine default)"); - cmd->add_option("--top-p", params->top_p, "Keep the smallest token set above this probability"); - cmd->add_option("--top-k", params->top_k, "Sample from this many highest-probability tokens"); - cmd->add_option("--min-p", params->min_p, "Drop tokens below this share of the top token"); + "Sampling temperature (0 = engine default)") + ->group(kSampling); + cmd->add_option("--top-p", params->top_p, "Keep the smallest token set above this probability") + ->group(kSampling); + cmd->add_option("--top-k", params->top_k, "Sample from this many highest-probability tokens") + ->group(kSampling); + cmd->add_option("--min-p", params->min_p, "Drop tokens below this share of the top token") + ->group(kSampling); cmd->add_option("--repetition-penalty", params->repetition_penalty, - "Penalize tokens already present in the context"); - cmd->add_option("--seed", params->seed, "Fix the RNG for a repeatable answer"); + "Penalize tokens already in the context") + ->group(kSampling); + cmd->add_option("--seed", params->seed, "Fix the RNG for a repeatable answer")->group(kSampling); cmd->add_option("--frequency-penalty", params->frequency_penalty, - "Penalize tokens by how often they have appeared"); + "Penalize tokens by how often they appeared") + ->group(kSampling); cmd->add_option("--presence-penalty", params->presence_penalty, - "Penalize tokens that appeared at all"); - cmd->add_option("--stop", params->stop_sequences, - "Stop as soon as this text is produced (repeat for several)"); + "Penalize tokens that appeared at all") + ->group(kSampling); + cmd->add_option("--stop", params->stop_sequences, "Stop at this text (repeat for several)") + ->group(kSampling); cmd->add_option("--max-output-tokens,--max-tokens", params->max_output_tokens, - "Cap the generated tokens (default 1024)") + "Cap on generated tokens (default 1024)") // Range, not PositiveNumber, for the message alone (mirrors // cmd_bench.cpp's --trials): 0 or negative used to reach the engine // as-is and read as "no cap" — full/whole-context output — instead of // the usage error a nonsensical budget should be. - ->check(CLI::Range(1, std::numeric_limits::max())); - cmd->add_option("--reasoning", params->reasoning, - "Turn the model's thinking phase on or off (default on)") - ->check(CLI::IsMember({"on", "off"})); + ->check(CLI::Range(1, std::numeric_limits::max())) + ->group(kSampling); + cmd->add_option("--reasoning", params->reasoning, "Model thinking phase (default on)") + ->check(CLI::IsMember({"on", "off"})) + ->group(kReasoning); cmd->add_flag("--show-thinking,!--hide-thinking", params->show_thinking, - "Stream thought tokens to stderr (default on)"); + "Print thinking tokens on stderr (default on)") + ->group(kReasoning); cmd->add_flag_callback( - "--no-think", [params]() { params->reasoning = "off"; }, - "Older spelling of `--reasoning off`"); + "--no-think", [params]() { params->reasoning = "off"; }, "Same as --reasoning off") + ->group(kReasoning); } } // namespace @@ -738,12 +748,12 @@ void configure_llm(CLI::App* cmd, GlobalOptions& options, LlmVerb verb, ModelArg auto prompt = std::make_shared(); add_generation_options(cmd, params, model_arg, false); cmd->add_option("prompt", *prompt, - verb == LlmVerb::Chat ? "First prompt (omit for the interactive REPL)" + verb == LlmVerb::Chat ? "Prompt to answer (omit for an interactive chat)" : "Prompt to complete (omit to read stdin)"); if (verb == LlmVerb::Chat) { // The REPL and VLM paths share one implementation; `run --image` stays // the documented alias of `vlm generate`. - cmd->add_option("--image", params->image, "Describe this image instead (VLM models)") + cmd->add_option("--image", params->image, "Ask about this image instead (vision models)") ->check(CLI::ExistingFile); } cmd->callback([&options, verb, params, prompt]() { @@ -773,10 +783,19 @@ void configure_vlm_generate(CLI::App* cmd, GlobalOptions& options) { void register_llm(CLI::App& app, GlobalOptions& options) { CLI::App* ns = app.add_subcommand("llm", "Generate text with a language model"); ns->require_subcommand(1); - configure_llm(ns->add_subcommand("generate", "Complete a prompt and print the result"), - options, LlmVerb::Generate, ModelArg::Option); - configure_llm(ns->add_subcommand("stream", "Complete a prompt, printing tokens as they arrive"), - options, LlmVerb::Stream, ModelArg::Option); + configure_llm( + ns->add_subcommand("generate", "Complete a prompt, printed when done") + ->footer(examples_footer({ + {"wally llm generate -m qwen3-0.6b \"explain tunnelling\"", ""}, + {"echo \"summarise this\" | wally llm generate -m qwen3-0.6b", ""}, + })), + options, LlmVerb::Generate, ModelArg::Option); + configure_llm( + ns->add_subcommand("stream", "Complete a prompt, printed as it arrives") + ->footer(examples_footer({ + {"wally llm stream -m qwen3-0.6b \"tell me a short story\"", ""}, + })), + options, LlmVerb::Stream, ModelArg::Option); } void register_vlm(CLI::App& app, GlobalOptions& options) { @@ -787,11 +806,14 @@ void register_vlm(CLI::App& app, GlobalOptions& options) { } void register_llm_aliases(CLI::App& app, GlobalOptions& options) { - configure_llm(app.add_subcommand("run", "Chat with a model (alias of `llm stream`)"), options, - LlmVerb::Chat, ModelArg::Positional); - configure_llm( - app.add_subcommand("chat", "Start an interactive session (alias of `llm stream`)"), - options, LlmVerb::Chat, ModelArg::Positional); + // `run` is the interactive model runner (prompt, or a REPL when omitted). + // `llm generate` / `llm stream` are the explicit, manual entry points. + configure_llm(app.add_subcommand("run", "Run a model") + ->footer(examples_footer({ + {"wally run qwen3-0.6b", "Chat interactively"}, + {"wally run qwen3-0.6b \"write a haiku\"", "Answer one prompt"}, + })), + options, LlmVerb::Chat, ModelArg::Positional); } } // namespace wally::commands diff --git a/src/commands/cmd_serve.cpp b/src/commands/cmd_serve.cpp index 72efe931..cbf449a7 100644 --- a/src/commands/cmd_serve.cpp +++ b/src/commands/cmd_serve.cpp @@ -21,6 +21,7 @@ #include "rac/server/rac_server.h" #endif +#include "cli_formatter.h" #include "commands/model_setup.h" #include "io/output.h" @@ -103,9 +104,12 @@ int run_serve(const GlobalOptions& options, const std::string& ref, const std::s } // namespace void register_serve(CLI::App& app, GlobalOptions& options) { - CLI::App* cmd = - app.add_subcommand("serve", "Serve a model over an OpenAI-compatible HTTP API"); + CLI::App* cmd = app.add_subcommand("serve", "Serve a model over an OpenAI-compatible API"); #if defined(WALLY_HAS_SERVER) + cmd->footer(examples_footer({ + {"wally serve qwen3-0.6b", ""}, + {"wally serve granite-4.2-8b --port 8000", ""}, + })); auto ref = std::make_shared(); auto host = std::make_shared("127.0.0.1"); auto port = std::make_shared(8080); @@ -114,13 +118,13 @@ void register_serve(CLI::App& app, GlobalOptions& options) { auto gpu_layers = std::make_shared(0); auto cors = std::make_shared(false); cmd->add_option("model", *ref, - "LLM to serve (default: " + std::string(kDefaultServeModel) + ")"); - cmd->add_option("--host,-H", *host, "Bind to this address (default 127.0.0.1)"); - cmd->add_option("--port,-p", *port, "Listen on this port (default 8080)"); + "Model to serve (default " + std::string(kDefaultServeModel) + ")"); + cmd->add_option("--host,-H", *host, "Address to bind (default 127.0.0.1)"); + cmd->add_option("--port,-p", *port, "Port to listen on (default 8080)"); cmd->add_option("--context-length,--context,-c", *context, - "Size the context window in tokens (default 8192)"); - cmd->add_option("--threads,-t", *threads, "Run inference on this many threads (default 4)"); - cmd->add_option("--gpu-layers,--ngl", *gpu_layers, "Offload this many layers to the GPU"); + "Context window in tokens (default 8192)"); + cmd->add_option("--threads,-t", *threads, "Inference threads (default 4)"); + cmd->add_option("--gpu-layers,--ngl", *gpu_layers, "Layers to offload to the GPU"); cmd->add_flag("--cors", *cors, "Allow cross-origin browser requests (off by default)"); cmd->callback([&options, ref, host, port, context, threads, gpu_layers, cors]() { const int exit_code = run_serve(options, *ref, *host, *port, *context, *threads, diff --git a/src/commands/cmd_show.cpp b/src/commands/cmd_show.cpp index ea4f4eab..f3453392 100644 --- a/src/commands/cmd_show.cpp +++ b/src/commands/cmd_show.cpp @@ -1,7 +1,7 @@ /** * @file cmd_show.cpp - * @brief `wally models get ` (alias `wally show`) — registry entry - * details. + * @brief `wally models show ` (alias `wally models get`) — registry + * entry details. */ #include "commands/commands.h" diff --git a/src/commands/cmd_tool.cpp b/src/commands/cmd_tool.cpp index f2f25d61..9685d5b6 100644 --- a/src/commands/cmd_tool.cpp +++ b/src/commands/cmd_tool.cpp @@ -26,6 +26,7 @@ #include "bootstrap.h" #include "catalog/model_ref.h" +#include "cli_formatter.h" #include "commands/engine_options.h" #include "io/output.h" #include "io/proto.h" @@ -253,15 +254,16 @@ int run_tool_call(const GlobalOptions& options, const ToolCallParams& params) { void configure_tool_call(CLI::App* cmd, GlobalOptions& options) { auto params = std::make_shared(); cmd->add_option("prompt", params->prompt, "What to ask the model")->required(); - cmd->add_option("--model,-m", params->model, "Model to use for the tool-calling loop") + cmd->add_option("--model,-m", params->model, + "Model to use (default " + std::string(kDefaultToolModel) + ")") ->default_val(kDefaultToolModel); - cmd->add_option("--engine", params->engine, "Pin a specific inference engine"); + cmd->add_option("--engine", params->engine, "Engine to run on"); cmd->add_option("--tool-choice", params->tool_choice, - "How the model may call tools: auto|required|none|specific"); + "When the model may call tools: auto, required, none, specific"); cmd->add_option("--force-tool", params->force_tool, "Force one tool by name (implies --tool-choice specific)"); cmd->add_option("--max-tool-calls", params->max_tool_calls, - "Maximum host tool executions per turn"); + "Cap on tool calls per turn (default 3)"); cmd->callback([&options, params]() { const int exit_code = run_tool_call(options, *params); if (exit_code != 0) { @@ -277,9 +279,11 @@ void register_tool(CLI::App& app, GlobalOptions& options) { // that register_llm() already created (app.cpp registers llm first). CLI::App* ns = app.get_subcommand("llm"); configure_tool_call( - ns->add_subcommand("tool-call", - "Run the tool-calling loop with built-in demo tools (get_weather, " - "calculate)"), + ns->add_subcommand("tool-call", "Try tool calling with two demo tools") + ->footer(examples_footer({ + {"wally llm tool-call \"weather in Paris?\"", "Calls get_weather"}, + {"wally llm tool-call \"what is 19 * 23?\"", "Calls calculate"}, + })), options); } diff --git a/src/commands/cmd_update.cpp b/src/commands/cmd_update.cpp index 3390dcdd..41575c90 100644 --- a/src/commands/cmd_update.cpp +++ b/src/commands/cmd_update.cpp @@ -30,38 +30,41 @@ constexpr const char* kInstallUrl = } // namespace +int run_update(bool nightly) { +#if defined(_WIN32) + static_cast(nightly); + // The installer is a POSIX shell script; the Windows bottle updates + // through its own channel, not this command. + out::error_line("wally update is not available on Windows; reinstall from the release page"); + return 1; +#else + // WALLY_VERSION is a compile-time constant and the flag is a fixed token, + // so the command line carries nothing a caller could inject. + std::string command = "curl -fsSL "; + command += kInstallUrl; + command += " | sh -s --"; + if (nightly) { + command += " --nightly"; + } + command += " --version="; + command += WALLY_VERSION; + + out::status_line("checking for a newer wally..."); + // std::system returns a wait-status, not the exit code; a non-zero one + // means the installer already explained why on its own stderr. + return std::system(command.c_str()) != 0 ? 1 : 0; +#endif +} + void register_update(CLI::App& app, GlobalOptions& options) { static_cast(options); auto nightly = std::make_shared(false); - CLI::App* cmd = app.add_subcommand("update", "update wally to the latest release"); - cmd->add_flag("--nightly", *nightly, "track the development channel instead of production"); + CLI::App* cmd = app.add_subcommand("update", "Update wally to the latest release"); + cmd->add_flag("--nightly", *nightly, "Track the development channel"); cmd->callback([nightly]() { -#if defined(_WIN32) - static_cast(nightly); - // The installer is a POSIX shell script; the Windows bottle updates - // through its own channel, not this command. - out::error_line( - "wally update is not available on Windows; reinstall from the release page"); - throw CLI::RuntimeError(1); -#else - // WALLY_VERSION is a compile-time constant and the flag is a fixed - // token, so the command line carries nothing a caller could inject. - std::string command = "curl -fsSL "; - command += kInstallUrl; - command += " | sh -s --"; - if (*nightly) { - command += " --nightly"; - } - command += " --version="; - command += WALLY_VERSION; - - out::status_line("checking for a newer wally..."); - // std::system returns a wait-status, not the exit code; a non-zero one - // means the installer already explained why on its own stderr. - if (std::system(command.c_str()) != 0) { + if (run_update(*nightly) != 0) { throw CLI::RuntimeError(1); } -#endif }); } diff --git a/src/commands/cmd_usage.cpp b/src/commands/cmd_usage.cpp index f9cca86c..9dae7826 100644 --- a/src/commands/cmd_usage.cpp +++ b/src/commands/cmd_usage.cpp @@ -1,10 +1,12 @@ #include #include #include +#include #include #include "account/console.h" #include "account/credentials.h" +#include "cli_formatter.h" #include "commands/commands.h" #include "io/output.h" @@ -44,7 +46,7 @@ bool RefreshSession(const account::ConsoleClient& client, account::Credentials* std::string* error) { if (credentials->refresh_token.empty()) { if (error != nullptr) { - *error = "the cloud session cannot be refreshed; run `wally login`"; + *error = "the cloud session cannot be refreshed; run `wally account login`"; } return false; } @@ -153,7 +155,7 @@ int Usage(bool as_json) { return 1; } if (!credentials.signed_in()) { - out::error_line("not signed in — run `wally login`"); + out::error_line("not signed in — run `wally account login`"); return 1; } @@ -183,7 +185,7 @@ int Usage(bool as_json) { // was is not something we know — and sending someone to re-login // over a revoked key wastes the trip. out::error_line("the console rejected this session (" + refresh_failure + - "); run `wally login`"); + "); run `wally account login`"); return 1; } usage = account::Usage{}; @@ -208,8 +210,31 @@ int Usage(bool as_json) { void register_usage(CLI::App& app, GlobalOptions& options) { auto as_json = std::make_shared(false); - auto* usage = app.add_subcommand("usage", "credit left, and what the last day cost"); - usage->add_flag("--json", *as_json, "machine-readable output"); + // Lives under `account`. register_account runs first (app.cpp), so the + // namespace normally exists by now, but that ordering is only a comment + // over there, not something the type system enforces. A future reorder, + // or any other caller that reaches for register_usage on its own, would + // otherwise hit CLI11's bare OptionNotFound here — and configure_app() + // runs ahead of wally_run_main's own try/catch, so nothing downstream + // would catch it either. Guard the lookup the same way app.cpp guards its + // own get_subcommand(hidden) calls, and say plainly what went wrong + // instead of crashing on an unhandled exception. + CLI::App* account_cmd = nullptr; + try { + account_cmd = app.get_subcommand("account"); + } catch (const CLI::OptionNotFound&) { + out::error_line( + "internal error: register_usage() ran before register_account() registered " + "the `account` command"); + std::exit(1); + } + auto* usage = + account_cmd->add_subcommand("usage", "Show remaining credit and the last day's spend"); + usage->add_flag("--json", *as_json, "Print as JSON"); + usage->footer(examples_footer({ + {"wally account usage", ""}, + {"wally --json account usage", ""}, + })); // `wally --json usage` and `wally usage --json` mean the same thing. The root // parser accepts the first, so reading only the command-local flag printed a // human table to something asking for one JSON document. diff --git a/src/commands/cmd_version.cpp b/src/commands/cmd_version.cpp index f6ebf7e5..410ff287 100644 --- a/src/commands/cmd_version.cpp +++ b/src/commands/cmd_version.cpp @@ -24,7 +24,7 @@ namespace wally::commands { void register_version(CLI::App& app, GlobalOptions& options) { - CLI::App* cmd = app.add_subcommand("version", "Show wally and commons versions"); + CLI::App* cmd = app.add_subcommand("version", "Show wally and SDK versions"); cmd->callback([&options]() { const rac_version_t commons = rac_get_version(); const std::string commons_version = diff --git a/src/commands/commands.h b/src/commands/commands.h index 39363b6d..1ad6b73c 100644 --- a/src/commands/commands.h +++ b/src/commands/commands.h @@ -65,6 +65,11 @@ void register_backends(CLI::App& app, GlobalOptions& options); void register_help(CLI::App& app, GlobalOptions& options); void register_uninstall(CLI::App& app, GlobalOptions& options); +// Actions shared by their subcommands and the top-level `-u/--update` and +// `--uninstall` flags. Return a process exit code (0 on success). +int run_update(bool nightly); +int run_uninstall(bool yes); + /** One registered engine, folded across every primitive it advertises. */ struct EngineRow { std::string display_name; @@ -79,6 +84,16 @@ struct EngineRow { * shared by `wally backends` and `wally about`. */ std::map collect_backend_rows(); + +/** + * The rows `about` and `info` show: collect_backend_rows() narrowed to engines + * that serve generate_text. TEMP(llm-only cut): onnx (diarize, embed, segment) + * and sherpa (voice) stay registered and keep answering what the kit routes to + * them, but listing them beside commands that cannot reach them only raises + * questions. `wally backends` is the diagnostic and keeps the full list, which + * is also what the e2e `assert-backends.sh` checks. + */ +std::map collect_llm_backend_rows(); void register_serve(CLI::App& app, GlobalOptions& options); void register_bench(CLI::App& app, GlobalOptions& options); void register_auth(CLI::App& app, GlobalOptions& options); diff --git a/src/commands/engine_options.cpp b/src/commands/engine_options.cpp index 0995e0e7..782e8efc 100644 --- a/src/commands/engine_options.cpp +++ b/src/commands/engine_options.cpp @@ -27,10 +27,21 @@ bool parse_engine_hint(const std::string& engine, // files are and there is no NEURT value in InferenceFramework. `coreml` is // accepted as an alias: it is the engine's former name and remains the honest // name of the framework, so a user typing either means the same thing. + // Only a kit that linked NeuRT can honour it (bootstrap.cpp registers the + // plugin under the same macro); anywhere else the name is refused up front + // rather than letting the load fall through to MLX and fail on a Core ML + // tree with a confusing "config.json not found". if (normalized == "neurt" || normalized == "coreml" || normalized == "core-ml" || normalized == "ane") { +#if defined(WALLY_HAS_NEURT) *out_framework = runanywhere::v1::INFERENCE_FRAMEWORK_COREML; return true; +#else + if (error) { + *error = "engine '" + engine + "' (Apple Neural Engine) is not in this build"; + } + return false; +#endif } if (normalized == "llamacpp" || normalized == "llama.cpp" || normalized == "llama_cpp" || normalized == "llama-cpp") { @@ -56,6 +67,20 @@ bool parse_engine_hint(const std::string& engine, return false; } +const char* engine_choices() { + // Built from the same kit macros parse_engine_hint() gates on, so a help + // page never advertises an engine this binary cannot register. + return +#if defined(WALLY_HAS_NEURT) + "neurt|coreml|ane, " +#endif + "mlx, llamacpp, onnx, sherpa" +#if defined(WALLY_HAS_QHEXRT) + ", qhexrt" +#endif + ; +} + bool resolve_engine_hint(const std::string& engine, EngineHintResolution* out_resolution, std::string* error) { if (!out_resolution) { diff --git a/src/commands/engine_options.h b/src/commands/engine_options.h index 0a54e95b..a0c808f6 100644 --- a/src/commands/engine_options.h +++ b/src/commands/engine_options.h @@ -23,6 +23,11 @@ bool parse_engine_hint(const std::string& engine, runanywhere::v1::InferenceFramework* out_framework, std::string* error); +/// The `--engine` values this build accepts, for help text: "mlx, llamacpp, +/// onnx, sherpa", with NeuRT and QHexRT names added only when the kit linked +/// them. Keeps every command's help in step with parse_engine_hint(). +const char* engine_choices(); + bool resolve_engine_hint(const std::string& engine, EngineHintResolution* out_resolution, std::string* error); diff --git a/src/commands/model_labels.h b/src/commands/model_labels.h index 6e23b07a..5f7886ab 100644 --- a/src/commands/model_labels.h +++ b/src/commands/model_labels.h @@ -81,6 +81,25 @@ inline const char* backend(v1::InferenceFramework framework) { } } +// Compact backend tags for the merged `models list` BACKEND column, where one +// model's per-backend variants collapse to a single row (e.g. "mlx/llama.cpp"). +inline const char* short_backend(v1::InferenceFramework framework) { + switch (framework) { + case v1::INFERENCE_FRAMEWORK_MLX: + return "mlx"; + case v1::INFERENCE_FRAMEWORK_LLAMA_CPP: + return "llama.cpp"; + case v1::INFERENCE_FRAMEWORK_COREML: + return "ane"; + case v1::INFERENCE_FRAMEWORK_QHEXRT: + return "npu"; + case v1::INFERENCE_FRAMEWORK_ONNX: + return "onnx"; + default: + return backend(framework); + } +} + inline const char* format(v1::ModelFormat format) { switch (format) { case v1::MODEL_FORMAT_GGUF: diff --git a/src/commands/model_setup.h b/src/commands/model_setup.h index 661afe08..27b60c5f 100644 --- a/src/commands/model_setup.h +++ b/src/commands/model_setup.h @@ -2,7 +2,8 @@ * @file model_setup.h * @brief Shared ensure-downloaded + resolve-paths step for speech commands. * - * Resolves a model ref, pulls it when missing (same flow as `wally pull`), and + * Resolves a model ref, pulls it when missing (same flow as `wally models + * pull`), and * resolves the on-disk artifact paths through commons' * rac_model_lifecycle_resolve_paths_proto — no engine load, no path guessing * in the CLI. diff --git a/src/harness/agents.cpp b/src/harness/agents.cpp index 1ee0720c..550965fd 100644 --- a/src/harness/agents.cpp +++ b/src/harness/agents.cpp @@ -22,6 +22,7 @@ #include "account/console.h" #include "account/credentials.h" #include "harness/harness.h" +#include "harness/local_models.h" #include "io/output.h" namespace wally::harness { @@ -236,13 +237,11 @@ struct ModelLimits { std::int64_t output_per_mtok = 0; }; -/// The context size `harness::Resolve` starts a local server with. -constexpr std::int64_t kLocalContextSize = 8192; - ModelLimits LookupLimits(const Endpoint& endpoint, const std::string& model) { ModelLimits limits; if (endpoint.api_key.empty()) { - limits.context_window = kLocalContextSize; + // The size `harness::Resolve` started the local server with. + limits.context_window = LocalContextSize(model); return limits; } @@ -303,14 +302,14 @@ std::string ReadOpenClawConfig() { } // namespace const Agent kAgents[] = { - {"hermes", "hermes", "open a Hermes coding session against a model", - Agent::Handoff::CustomEndpointEnvironment, "--tui"}, - {"openclaw", "openclaw", "open OpenClaw against a model", Agent::Handoff::ConfigFile, + {"hermes", "hermes", "Open Hermes with a model", Agent::Handoff::CustomEndpointEnvironment, + "--tui"}, + {"openclaw", "openclaw", "Open OpenClaw with a model", Agent::Handoff::ConfigFile, "tui --local"}, // No default arguments: this row picks its own profile below, and a `web` // default would arrive here as the person's first positional — which is to // say, as a prompt. - {"deepseek", "dsh", "open DeepSeek Harness against a model", Agent::Handoff::PatchOverlay, ""}, + {"deepseek", "dsh", "Open DeepSeek Harness with a model", Agent::Handoff::PatchOverlay, ""}, }; const int kAgentCount = static_cast(sizeof(kAgents) / sizeof(kAgents[0])); @@ -477,13 +476,15 @@ std::string BuildDeepSeekSettings(const std::string& base_url, const std::string nlohmann::json provider = {{"displayName", "RunAnywhere"}, {"api", "openai-completions"}, {"baseURL", base_url}, - {"models", std::move(entries)}}; - // Omitted for a local server: an absent reference leaves the route keyless, - // which is what a loopback endpoint wants. A reference that resolves to - // nothing would fail every request with MISSING_CREDENTIAL instead. - if (!key_variable.empty()) { - provider["apiKeyEnv"] = key_variable; - } + {"models", std::move(entries)}, + // Always referenced, local server included. This used to + // be omitted for a loopback endpoint on the theory that + // no reference meant a keyless route; dsh 0.1.5 instead + // refuses the turn with "No API key for provider: + // runanywhere" before any request is made. The variable + // carries a placeholder for a local server, which ignores + // the Authorization header anyway. + {"apiKeyEnv", key_variable}}; const nlohmann::json settings = { {"llm-pi-ai", {{"providers", {{kProviderId, provider}}}}}}; return settings.dump(); @@ -623,26 +624,24 @@ int LaunchAgent(const Agent& agent, const std::string& model, out::status_line("context window: " + std::to_string(catalog.front().context_window) + " tokens"); } - const std::string key_variable = - endpoint.api_key.empty() ? std::string() : std::string(kDeepSeekKeyVariable); - std::string failure; - if (!settings.Write(BuildDeepSeekSettings(endpoint.base_url, key_variable, catalog), - &failure) || + if (!settings.Write( + BuildDeepSeekSettings(endpoint.base_url, kDeepSeekKeyVariable, catalog), + &failure) || !config.Write(BuildDeepSeekPatch(settings.path(), model), &failure, ".yml")) { out::error_line(failure); Release(endpoint); return 1; } - std::unique_ptr key; - if (!key_variable.empty()) { - key = std::make_unique(kDeepSeekKeyVariable, endpoint.api_key); - if (!key->applied()) { - out::error_line("could not set the endpoint for " + std::string(agent.id)); - Release(endpoint); - return 1; - } + // The real key for a hosted model; a placeholder for a local server, + // which dsh insists on having and the server never reads. + const ScopedEnv key(kDeepSeekKeyVariable, + endpoint.api_key.empty() ? "local" : endpoint.api_key); + if (!key.applied()) { + out::error_line("could not set the endpoint for " + std::string(agent.id)); + Release(endpoint); + return 1; } // `--patch` belongs to the launcher, so it goes ahead of anything diff --git a/src/harness/catalog_models.cpp b/src/harness/catalog_models.cpp index 3fc4bbaf..dd631b11 100644 --- a/src/harness/catalog_models.cpp +++ b/src/harness/catalog_models.cpp @@ -1,5 +1,7 @@ #include "harness/catalog_models.h" +#include "harness/local_models.h" + #include #include @@ -9,7 +11,6 @@ namespace wally::harness { namespace { /// The context size `harness::Resolve` starts a local server with. -constexpr std::int64_t kLocalContextSize = 8192; /// Moves the entry whose id is `primary` to the front, or inserts a bare one /// when the catalog did not carry it — the launched model is always selectable. @@ -67,7 +68,7 @@ std::vector CatalogModels(const std::string& console_url, std::vector CatalogModels(const Endpoint& endpoint, const std::string& primary) { if (endpoint.api_key.empty()) { - return {CatalogModel{primary, kLocalContextSize, 0, 0, 0}}; + return {CatalogModel{primary, LocalContextSize(primary), 0, 0, 0}}; } return CatalogModels(endpoint.console_url, endpoint.api_key, primary); } diff --git a/src/harness/harness.cpp b/src/harness/harness.cpp index 34054bc1..051cee8c 100644 --- a/src/harness/harness.cpp +++ b/src/harness/harness.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -35,6 +36,7 @@ using wally_socklen_t = socklen_t; #include "io/output.h" #include "bootstrap.h" #include "harness/catalog_models.h" +#include "catalog/catalog.h" #include "harness/local_models.h" namespace wally::harness { @@ -364,7 +366,7 @@ long long EpochSeconds() { .count(); } -/// The refresh half of the same dance `wally usage` uses: exchange the refresh +/// The refresh half of the same dance `wally account usage` uses: exchange the refresh /// token for a new access token and persist it, so later commands in the same /// session do not pay for the refresh again. bool RefreshSession(const account::ConsoleClient& console, account::Credentials* credentials, @@ -374,7 +376,7 @@ bool RefreshSession(const account::ConsoleClient& console, account::Credentials* } if (credentials->refresh_token.empty()) { if (error != nullptr) { - *error = "the cloud session cannot be refreshed; run `wally login`"; + *error = "the cloud session cannot be refreshed; run `wally account login`"; } return false; } @@ -485,31 +487,65 @@ bool Resolve(const std::string& model, Endpoint* endpoint) { std::string console_url; bool serving = false; + // The names a person types (`bonsai-27b`, `mlx-qwen3-0.6b`, `qwen3`) are + // catalog ids, aliases and `models list` merge keys; the directory on disk + // is the registry id (`mlx-qwen3-0.6b-4bit`). Accept every spelling the + // catalog does, the same way `run` and `models pull` do, and prefer a + // downloaded variant of a merged row over one that is not here. + std::vector wanted{model}; + if (const catalog::CatalogEntry* entry = catalog::find(model)) { + wanted.push_back(entry->id); + } + size_t count = 0; + const catalog::CatalogEntry* all = catalog::all(&count); + for (size_t i = 0; i < count; ++i) { + if (catalog::merge_key_for(all[i].id) == model) { + wanted.push_back(all[i].id); + } + } + + // First spelling wins, except that a directory with no weight file in it + // (a cancelled pull leaves the manifest and a `.part`) loses to any variant + // whose weights are actually there. Beyond that the load below is what + // decides whether the model opens; the walk cannot judge completeness. const LocalModel* local = nullptr; const std::vector installed = LocalModels(env.home); - for (const LocalModel& candidate : installed) { - // Completeness used to be checked against the catalog's file list. - // The walk cannot do that, and does not need to: it only yields a - // directory that already holds weights or a download manifest, and the - // load below is what actually decides whether the model opens. - if (candidate.id == model) { - local = &candidate; - break; + for (const std::string& id : wanted) { + for (const LocalModel& candidate : installed) { + if (candidate.id != id) continue; + const bool has_weights = !candidate.path.empty() || candidate.framework == "CoreML"; + if (local == nullptr || (has_weights && local->path.empty())) { + local = &candidate; + } } + if (local != nullptr && !local->path.empty()) break; } if (local != nullptr) { - // The server creates its handle with rac_llm_create(path), which routes - // on the path alone rather than asking the registry what framework the - // model belongs to. An MLX directory does not look like anything it - // recognises, so it lands on llama.cpp and fails to load. Saying so - // beats starting a server that answers every request with an error. - if (local->framework != "LlamaCpp") { - out::error_line(model + " runs on " + local->framework + - ", and the local server can only serve LlamaCpp models today"); - out::status_line("use a GGUF model here, or point at an upstream one"); + // A directory with the manifest but no weights is a pull that did not + // finish. Serving it fails inside llama.cpp with "No .gguf file found", + // which reads as a bug; say what it is instead. + if (local->path.empty() && local->framework != "CoreML") { + out::error_line(model + " is on this machine but incomplete (a cancelled download?)"); + out::status_line("run `wally models pull " + model + "` to finish it"); return false; } + // Coding tools are cloud-only this release. A local model is refused + // outright rather than gated: the kit's local server re-reads the whole + // conversation every turn and leaks a reasoning model's thinking into + // the reply, so an agent degrades from the second turn on. `wally run` + // still takes any local model; the harnesses take a hosted one. + out::error_line(model + " is on this machine, but coding tools run on hosted models only"); + out::status_line("sign in and use one: `wally account login`, then `wally opencode --cloud -m glm-5.3-flash`"); + return false; + // Any backend the kit registered. The server's rac_llm_create(path) + // looks the path up in the registry and routes on the framework it + // finds ("Found model by path ... framework=7 ... Routed to plugin: + // mlx", kit 0.20.37), so an MLX directory reaches MLX the same way a + // GGUF reaches llama.cpp. An older kit routed on the path alone and + // this used to refuse anything but LlamaCpp here; the load below is + // now the honest gate, and it fails loudly for a framework this + // binary does not have. const int port = FreePort(); if (port == 0) { out::error_line("could not find a free port for the local server"); @@ -528,13 +564,21 @@ bool Resolve(const std::string& model, Endpoint* endpoint) { rac_server_config_t config = RAC_SERVER_CONFIG_DEFAULT; config.host = "127.0.0.1"; config.port = static_cast(port); - const std::string path = local->path.empty() ? local->dir : local->path; + // A single-file model (GGUF) is its file; a directory model (MLX + // safetensors shards, Core ML) is its directory, which is also what + // `wally serve` hands the server. Passing one shard of three worked only + // because the server resolves the path through the registry. + const std::string path = local->framework == "LlamaCpp" && !local->path.empty() + ? local->path + : local->dir; config.model_path = path.c_str(); config.model_id = model.c_str(); - // The per-run context setting went with the old CLI; the server default - // it fell back to is what every run used in practice anyway. - config.context_size = 8192; - out::status_line("serving " + model + " on 127.0.0.1:" + std::to_string(port)); + // Sized from this machine, not a constant: a coding agent's opening + // request is a 15k-token system prompt, and the fixed 8k this used to + // pass rejected it. See LocalContextSize. + config.context_size = static_cast(LocalContextSize(local->id)); + out::status_line("serving " + model + " on 127.0.0.1:" + std::to_string(port) + " (" + + std::to_string(config.context_size) + " token context)"); if (rac_server_start(&config) != RAC_SUCCESS) { out::error_line("the local server would not start for " + model); return false; @@ -569,8 +613,8 @@ bool Resolve(const std::string& model, Endpoint* endpoint) { // hand-written credentials.json satisfies it with any non-empty // string. Everything past this point is destructive to a caller's // running app or session, so confirm the session against the console - // first — the same identity check `wally whoami` makes, with the same - // refresh-on-401 dance `wally usage` uses. + // first — the same identity check `wally account whoami` makes, with the + // same refresh-on-401 dance `wally account usage` uses. const account::ConsoleClient console; std::string email; std::string verify_error; @@ -630,18 +674,18 @@ bool EnsureInstalled(const std::string& tool) { void ReportCloudSessionInvalid(const std::string& model) { // Stderr, on its own line: a red "Error:" a person cannot miss, and the - // action `wally login` highlighted so the fix stands out. Color is dropped + // action `wally account login` highlighted so the fix stands out. Color is dropped // under NO_COLOR or when stderr is not a terminal. const cli_color::Palette pal = cli_color::make_palette(color_output_enabled(false)); out::status_line(std::string(pal.red) + "Error:" + pal.reset + " You cannot use " + model + - ", your cloud session is no longer valid, do: " + pal.bold_cyan + "wally login" + + ", your cloud session is no longer valid, do: " + pal.bold_cyan + "wally account login" + pal.reset + " and try again"); } void ReportNotSignedIn() { const cli_color::Palette pal = cli_color::make_palette(color_output_enabled(false)); out::status_line(std::string(pal.red) + "Error:" + pal.reset + - " You are not logged in, log in with " + pal.bold_cyan + "wally login" + + " You are not logged in, log in with " + pal.bold_cyan + "wally account login" + pal.reset); } diff --git a/src/harness/harness.h b/src/harness/harness.h index d87789b7..09dee728 100644 --- a/src/harness/harness.h +++ b/src/harness/harness.h @@ -1,6 +1,7 @@ #ifndef WALLY_HARNESS_HARNESS_H #define WALLY_HARNESS_HARNESS_H +#include #include #include @@ -83,7 +84,7 @@ bool EnsureInstalled(const std::string& tool); /// harness a person launched. void ReportCloudSessionInvalid(const std::string& model); -/// The one shared "you are not signed in" error, in red with `wally login` +/// The one shared "you are not signed in" error, in red with `wally account login` /// highlighted, for when no session is stored at all (as opposed to an expired /// one). Same look as ReportCloudSessionInvalid. void ReportNotSignedIn(); diff --git a/src/harness/local_models.cpp b/src/harness/local_models.cpp index 9494e8f7..6aea299f 100644 --- a/src/harness/local_models.cpp +++ b/src/harness/local_models.cpp @@ -1,10 +1,15 @@ #include "harness/local_models.h" #include +#include #include #include #include +#include "rac/core/rac_platform_adapter.h" + +#include "catalog/catalog.h" + namespace wally::harness { namespace { @@ -107,4 +112,39 @@ std::vector LocalModels(const std::string& home) { return models; } +std::int64_t LocalContextSize(const std::string& model_id) { + constexpr std::int64_t kFloor = 8192; + constexpr std::int64_t kGiB = 1024LL * 1024 * 1024; + + // Physical memory decides the tier. On Apple Silicon this is the unified + // pool the GPU draws from too, which is why it stands in for VRAM here. + std::int64_t tier = kFloor; + const rac_platform_adapter_t* adapter = rac_get_platform_adapter(); + rac_memory_info_t memory{}; + if (adapter != nullptr && adapter->get_memory_info != nullptr && + adapter->get_memory_info(&memory, adapter->user_data) == RAC_SUCCESS && + memory.total_bytes > 0) { + const std::int64_t total = static_cast(memory.total_bytes); + if (total >= 48 * kGiB) { + tier = 65536; + } else if (total >= 24 * kGiB) { + tier = 32768; + } else if (total >= 12 * kGiB) { + tier = 16384; + } + } + + // The model's own window caps it: asking llama.cpp for more than the model + // was trained on stretches RoPE and degrades every answer. 0 means the + // catalog does not know, and a model that is not in the catalog at all + // (an hf.co ref, a hand-placed folder) gets the tier as is. + std::int64_t window = tier; + if (const catalog::CatalogEntry* entry = catalog::find(model_id)) { + if (entry->context_length > 0) { + window = std::min(tier, static_cast(entry->context_length)); + } + } + return std::max(kFloor, window); +} + } // namespace wally::harness diff --git a/src/harness/local_models.h b/src/harness/local_models.h index e4fafe9c..4555e245 100644 --- a/src/harness/local_models.h +++ b/src/harness/local_models.h @@ -27,6 +27,15 @@ struct LocalModel { /// model placed by hand as readily as one that was downloaded. std::vector LocalModels(const std::string& home); +/// The context window a local server is started with for `model_id`, in +/// tokens. Sized from this machine's memory in tiers (8k / 16k / 32k / 64k), +/// because a coding agent's first request is a 15k-token system prompt and a +/// fixed 8k window rejected it outright. Capped at the model's own window when +/// the catalog knows it, never below 8192, which is what every launch used +/// before. One function, so the server, the picker's declared limits, and the +/// shim all quote the same number. +std::int64_t LocalContextSize(const std::string& model_id); + } // namespace wally::harness #endif // WALLY_HARNESS_LOCAL_MODELS_H diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 693322e2..6e640dca 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -4,6 +4,13 @@ enable_testing() add_executable(test_wally_unit test_wally_unit.cpp) target_include_directories(test_wally_unit PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") target_link_libraries(test_wally_unit PRIVATE wally_core nlohmann_json::nlohmann_json) +# The catalog hides rows whose backend this kit did not ship (see +# platform_supports() in src/catalog/catalog.cpp), and the unit tests assert on +# exactly that. wally_core gets these macros PRIVATE, so without this line the +# test TU cannot see which engines are present and would assert the wrong half +# of the matrix -- e.g. skipping the QHexRT row on a kit that ships it (or +# asserting one on a kit that does not). +wally_define_engine_macros(test_wally_unit) wally_stage_windows_runtime_dlls(test_wally_unit) add_test(NAME wally_unit_tests COMMAND test_wally_unit --run-all) diff --git a/tests/test_account_cli.py b/tests/test_account_cli.py index db9ad677..4080a676 100644 --- a/tests/test_account_cli.py +++ b/tests/test_account_cli.py @@ -20,7 +20,7 @@ # is 0 in the 24h window on purpose: SGLang does not report cached tokens for # glm-5.3, so zero is what a real console sends today and the row has to survive # it honestly rather than disappear. `timeline`, `models` and `recent` are -# present because the console sends them; `wally usage` ignores all three. +# present because the console sends them; `wally account usage` ignores all three. USAGE_WINDOWS = [ { "window": "1h", @@ -179,7 +179,7 @@ def main(): ): environment.pop(name, None) - login = run(binary, ["login", "--no-browser"], environment) + login = run(binary, ["account", "login", "--no-browser"], environment) if "ABCD-EFGH" not in login or ConsoleHandler.console_origin not in login: raise AssertionError("login did not print the approval code and URL") @@ -197,13 +197,13 @@ def main(): f"unsafe credential modes: {directory_mode:o}/{file_mode:o}" ) - whoami = run(binary, ["whoami"], environment) + whoami = run(binary, ["account", "whoami"], environment) if EMAIL not in whoami or "session" not in whoami or "active" not in whoami: raise AssertionError("whoami did not report the active identity") if "plan" in whoami or "tokens" in whoami or "quota" in whoami: raise AssertionError("whoami exposed launch-out-of-scope billing fields") - usage = run(binary, ["usage"], environment) + usage = run(binary, ["account", "usage"], environment) # What San asked for and nothing else: the balance, then input, # output, cache and money over two windows. for fragment in ("$18.42", "$25.00", "input", "output", "cache", "spend"): @@ -225,9 +225,10 @@ def main(): raise AssertionError(f"usage still prints {banned!r}:\n{usage}") # The root flag and the command flag mean the same thing. The root - # parser accepts `wally --json usage`, and reading only the local - # flag printed a human table to something asking for one document. - for argv in (["usage", "--json"], ["--json", "usage"]): + # parser accepts `wally --json account usage`, and reading only the + # local flag printed a human table to something asking for one + # document. + for argv in (["account", "usage", "--json"], ["--json", "account", "usage"]): combined = run(binary, argv, environment) # run() concatenates stderr, where status lines and SDK logs go. # The document is the one line that is a JSON object. @@ -243,7 +244,7 @@ def main(): # neither may be filled in from the month-wide `totals` next to it. ConsoleHandler.serves_windows = False try: - stale = run(binary, ["usage"], environment) + stale = run(binary, ["account", "usage"], environment) finally: ConsoleHandler.serves_windows = True for label in ("past 1h", "past 24h"): @@ -255,7 +256,7 @@ def main(): if "$18.42" not in stale: raise AssertionError(f"the balance is known and must still print:\n{stale}") - run(binary, ["logout"], environment) + run(binary, ["account", "logout"], environment) if list(pathlib.Path(profile).iterdir()): raise AssertionError("logout did not remove the local session") diff --git a/tests/test_wally_harness.cpp b/tests/test_wally_harness.cpp index efb6ea64..fab550bf 100644 --- a/tests/test_wally_harness.cpp +++ b/tests/test_wally_harness.cpp @@ -11,6 +11,7 @@ #include "account/credentials.h" #include "harness/agents.h" #include "harness/harness.h" +#include "harness/local_models.h" namespace { @@ -709,9 +710,10 @@ TestResult test_hermes_argv_pins_provider_and_model_ahead_of_the_rest() { } // dsh reads our provider out of a settings document it is pointed at, so the -// document is the contract. A missing apiKeyEnv on an upstream route fails -// every request with MISSING_CREDENTIAL; a present one on a loopback route -// does the same, because there is no key to resolve. +// document is the contract. A missing apiKeyEnv fails every turn with "No API +// key for provider: runanywhere" (dsh 0.1.5), on a loopback route as much as +// an upstream one, so the reference is always present and the launcher puts a +// placeholder in the variable for a local server. TestResult test_deepseek_settings_carry_the_route() { TestResult result; result.test_name = "deepseek_settings_carry_the_route"; @@ -736,9 +738,9 @@ TestResult test_deepseek_settings_carry_the_route() { } const Json local = Json::parse(wally::harness::BuildDeepSeekSettings( - "http://127.0.0.1:52431/v1", "", {{"qwen3-0.6b", 8192, 0, 0, 0}})); - if (local["llm-pi-ai"]["providers"]["runanywhere"].contains("apiKeyEnv")) { - result.details = "a keyless local route must not name a reference that resolves to nothing"; + "http://127.0.0.1:52431/v1", "RUNANYWHERE_API_KEY", {{"qwen3-0.6b", 8192, 0, 0, 0}})); + if (local["llm-pi-ai"]["providers"]["runanywhere"]["apiKeyEnv"] != "RUNANYWHERE_API_KEY") { + result.details = "a local route must still name the key reference, or dsh refuses the turn"; return result; } @@ -805,10 +807,44 @@ TestResult test_deepseek_prompt_picks_headless() { return result; } + +// The context a local server is started with never drops below the 8192 every +// launch used before, and never exceeds what the catalog says the model was +// trained on. The RAM tier in between depends on the machine, so only the two +// bounds and the unknown-model path are pinned here. +TestResult test_local_context_size_respects_floor_and_model_window() { + TestResult result; + result.test_name = "local_context_size_respects_floor_and_model_window"; + + // qwen3-0.6b's catalog window is 4096, below the floor: the floor wins. + if (wally::harness::LocalContextSize("qwen3-0.6b") != 8192) { + result.details = "a model window under 8192 must not pull the server below the floor"; + return result; + } + // A model the catalog has never heard of gets the machine's tier, which is + // at least the floor and a power of two the server accepts. + const std::int64_t unknown = wally::harness::LocalContextSize("hf.co/someone/some-model"); + if (unknown < 8192 || (unknown & (unknown - 1)) != 0) { + result.details = "an unknown model must get the RAM tier, >= 8192 and a power of two"; + return result; + } + // A catalog model is never given more than the tier an unknown one gets. + if (wally::harness::LocalContextSize("bonsai-27b") > unknown) { + result.details = "a catalog model must not exceed the machine's tier"; + return result; + } + result.passed = true; + return result; +} + + + } // namespace int main(int argc, char** argv) { TestSuite suite("wally_harness"); + suite.add("local_context_size_respects_floor_and_model_window", + test_local_context_size_respects_floor_and_model_window); suite.add("model_id_rejects_empty_and_control_characters", test_model_id_rejects_empty_and_control_characters); suite.add("model_id_rejects_xml_and_path_structural_characters", diff --git a/tests/test_wally_mlx_e2e.cpp b/tests/test_wally_mlx_e2e.cpp index feb06a6e..33c9d6d5 100644 --- a/tests/test_wally_mlx_e2e.cpp +++ b/tests/test_wally_mlx_e2e.cpp @@ -977,6 +977,12 @@ TestResult test_mlx_callback_bridge_all_slots() { return result; } +// TEMP(llm-only cut): embed/STT/TTS are unregistered in src/app.cpp (see the +// "TEMP(llm-only cut)" block there), so the sections of the test below that +// drive them cannot run. Flip WALLY_LLM_ONLY_CUT to 0 (here and in the +// sibling tests) when the full surface returns. +#define WALLY_LLM_ONLY_CUT 1 + TestResult test_wally_mlx_run_end_to_end() { TestResult result; result.test_name = "wally_mlx_run_end_to_end"; @@ -1003,7 +1009,9 @@ TestResult test_wally_mlx_run_end_to_end() { } const std::filesystem::path input_wav = home / "input.wav"; - const std::filesystem::path output_wav = home / "output.wav"; + // Only referenced inside the TTS section guarded by WALLY_LLM_ONLY_CUT + // below; keep [[maybe_unused]] only as long as that section stays disabled. + [[maybe_unused]] const std::filesystem::path output_wav = home / "output.wav"; const std::filesystem::path input_image = home / "image.rgb"; if (!write_file(input_image, "fake image")) { result.details = "failed to create fake VLM image"; @@ -1065,21 +1073,17 @@ TestResult test_wally_mlx_run_end_to_end() { std::string list_json; if (!run_cli_or_fail({"wally", "--json", "--no-progress", "--home", - home.string(), "list", "--all"}, - "list", &list_json, &result)) { + home.string(), "models", "list", "--all"}, + "models list", &list_json, &result)) { wally::shutdown(); return result; } - if (list_json.find("\"id\":\"mlx.fake.vlm\"") == std::string::npos || - list_json.find("\"modality\":\"vlm\"") == std::string::npos || - list_json.find("\"id\":\"mlx.fake.embed\"") == std::string::npos || - list_json.find("\"modality\":\"embedding\"") == std::string::npos || - list_json.find("\"id\":\"mlx.fake.stt\"") == std::string::npos || - list_json.find("\"modality\":\"stt\"") == std::string::npos || - list_json.find("\"backend\":\"MLX\"") == std::string::npos || - list_json.find("\"id\":\"mlx.fake.tts\"") == std::string::npos || - list_json.find("\"modality\":\"tts\"") == std::string::npos) { - result.expected = "MLX VLM/embedding/STT/TTS rows from wally list --all"; + // The LLM-only surface lists language models only; the non-LLM fakes are still + // registered and exercised by the run checks below, just not shown here. + if (list_json.find("\"id\":\"mlx.fake.llm\"") == std::string::npos || + list_json.find("\"modality\":\"llm\"") == std::string::npos || + list_json.find("\"backend\":\"mlx\"") == std::string::npos) { + result.expected = "MLX fake LLM row present in wally models list --all"; result.actual = list_json; wally::shutdown(); return result; @@ -1152,6 +1156,27 @@ TestResult test_wally_mlx_run_end_to_end() { return result; } + // embed/stt/tts are standalone top-level commands (register_embed/ + // register_stt/register_tts). `run`'s LLM/VLM coverage above is + // unaffected: it goes through register_llm_aliases, which the LLM-only + // cut does not touch. +#if WALLY_LLM_ONLY_CUT + // TEMP(llm-only cut): register_embed/register_stt/register_tts are + // commented out in src/app.cpp, so the sections below cannot run. This + // early return (shutdown + the create_count==2 gate) stands in for them + // and must go away in the same flip: reverting is WALLY_LLM_ONLY_CUT -> 0, + // which drops this whole branch and compiles the real embed/STT/TTS + // assertions under #else below instead -- no separate cleanup step. + wally::shutdown(); + if (g_mlx_state.create_count != 2 || g_mlx_state.initialize_count != 2) { + result.details = + "MLX create/initialize should run once per LLM/VLM model"; + return result; + } + + result.passed = true; + return result; +#else std::string embed_json; if (!run_cli_or_fail({"wally", "--json", "--no-progress", "--home", home.string(), "embed", "Hello MLX embeddings", @@ -1258,6 +1283,7 @@ TestResult test_wally_mlx_run_end_to_end() { result.passed = true; return result; +#endif // WALLY_LLM_ONLY_CUT } } // namespace diff --git a/tests/test_wally_segment.cpp b/tests/test_wally_segment.cpp index 3d620085..bcf8fc79 100644 --- a/tests/test_wally_segment.cpp +++ b/tests/test_wally_segment.cpp @@ -361,7 +361,17 @@ TestResult test_segment_usage_errors() { // Structural wiring of register_segment — positive spec assertion with ZERO // callback execution (the only way to verify the happy-path option spec without // a segmentation model). Uses CLI11 introspection on the registered subcommand. +// +// TEMP(llm-only cut): register_segment() is commented out in src/app.cpp, so +// the subcommand this test asserts on is unreachable. Flip +// WALLY_LLM_ONLY_CUT to 0 (here and in the sibling tests) when the full +// surface returns. While it is 1, this test is left OUT of the suite (see +// main() below) instead of reporting a bare `passed = true` -- ctest must not +// show a green test that asserts nothing. // ----------------------------------------------------------------------------- +#define WALLY_LLM_ONLY_CUT 1 + +#if !WALLY_LLM_ONLY_CUT TestResult test_segment_option_spec() { TestResult result; result.test_name = "segment_option_spec"; @@ -411,6 +421,7 @@ TestResult test_segment_option_spec() { result.passed = true; return result; } +#endif // !WALLY_LLM_ONLY_CUT // ----------------------------------------------------------------------------- // --json output-shape guard (mirrors test_json_writer_shape). @@ -498,7 +509,12 @@ int main(int argc, char** argv) { suite.add("read_ppm", test_read_ppm); suite.add("write_png_smoke", test_write_png_smoke); suite.add("segment_usage_errors", test_segment_usage_errors); +#if !WALLY_LLM_ONLY_CUT + // Not added while the cut is active: an unregistered test cannot report a + // false ctest PASS (see the TEMP(llm-only cut) comment on + // test_segment_option_spec above). suite.add("segment_option_spec", test_segment_option_spec); +#endif suite.add("segment_json_shape", test_segment_json_shape); return suite.run(argc, argv); } diff --git a/tests/test_wally_unit.cpp b/tests/test_wally_unit.cpp index 1f671f48..6e4db1ff 100644 --- a/tests/test_wally_unit.cpp +++ b/tests/test_wally_unit.cpp @@ -36,6 +36,8 @@ #include "catalog/catalog.h" #include "catalog/model_ref.h" #include "commands/bench_metrics.h" +#include "commands/commands.h" +#include "rac/plugin/rac_primitive.h" #include "commands/engine_options.h" #include "commands/model_labels.h" #include "config/cli_paths.h" @@ -45,6 +47,22 @@ #include "io/output.h" #include "io/proto.h" +// LLM-only cut (src/app.cpp): every non-LLM modality's register_*() call is +// commented out there for this release, so a subcommand like `diarize` or +// `rerank` is not registered at all. An unregistered subcommand fails CLI11's +// parse with the same ExtrasError -> exit 2 that a real argument-validation +// failure (missing --model, bad numeric option, unknown flag, ...) would +// also produce, so a test that only asserts "exit code == 2" can no longer +// tell the two apart -- it stays green whether or not the argument surface it +// names is ever reached. Guard that now-meaningless coverage with this flag +// instead of `/* */` (cannot nest) or a body swapped for +// `result.passed = true; return result;` (a silent, permanent green pass). +// Disabled tests are left out of `main()`'s suite.add() entirely rather than +// reported as a pass or fail, since TestResult/TestSuite (test_common.h) have +// no separate "skipped" status. Flip to 0 -- together with reverting the +// matching src/app.cpp registration comments -- to bring the coverage back. +#define WALLY_LLM_ONLY_CUT 1 + namespace { // setenv/unsetenv helper that restores prior state on scope exit. @@ -299,33 +317,76 @@ TestResult test_catalog_lookup() { size_t count = 0; const wally::catalog::CatalogEntry *entries = wally::catalog::all(&count); - if (!entries || count < 10) { + // all() lists only LLMs the linked kit can run (platform_supports() in + // src/catalog/catalog.cpp), so the floor and the probe rows track the kit + // macros. The public windows-arm64 kit has no LLM backend at all: no + // llama.cpp, and QHexRT reaches it only through the private overlay public + // CI never sees -- so neither WALLY_HAS_LLAMACPP nor WALLY_HAS_QHEXRT is + // defined there and the listed catalog is empty. +#if defined(WALLY_HAS_LLAMACPP) + constexpr size_t kMinEntries = 10; + const char *probe_id = "qwen3-0.6b"; + const char *probe_alias = "qwen3"; + const char *probe_partial = "qwen"; +#elif defined(WALLY_HAS_QHEXRT) + constexpr size_t kMinEntries = 3; + const char *probe_id = "lfm2_5_230m"; + const char *probe_alias = "lfm2-230m-npu"; + const char *probe_partial = "lfm2"; +#else + constexpr size_t kMinEntries = 0; + const char *probe_id = nullptr; + const char *probe_alias = nullptr; + const char *probe_partial = nullptr; +#endif + if (count < kMinEntries || (count > 0 && !entries)) { result.details = "catalog unexpectedly small"; return result; } - - const wally::catalog::CatalogEntry *by_id = wally::catalog::find("qwen3-0.6b"); - const wally::catalog::CatalogEntry *by_alias = wally::catalog::find("qwen3"); - if (!by_id || by_id != by_alias) { - result.details = "alias lookup should resolve to the same entry"; +#if !defined(WALLY_HAS_LLAMACPP) && !defined(WALLY_HAS_QHEXRT) && \ + !defined(__APPLE__) + // No LLM backend and no Apple engines: platform_supports() must hide every + // LLM row. A non-empty listing here means the kit gating broke. + if (count != 0) { + result.details = "expected an empty LLM catalog on a kit with no LLM backend"; return result; } +#endif + + if (probe_id != nullptr) { + const wally::catalog::CatalogEntry *by_id = + wally::catalog::find(probe_id); + const wally::catalog::CatalogEntry *by_alias = + wally::catalog::find(probe_alias); + if (!by_id || by_id != by_alias) { + result.details = "alias lookup should resolve to the same entry"; + return result; + } + } if (wally::catalog::find("definitely-not-a-model") != nullptr) { result.details = "unknown id should return nullptr"; return result; } - if (wally::catalog::suggestions("qwen", 3).empty()) { - result.details = "expected suggestions for 'qwen'"; + if (probe_partial != nullptr && + wally::catalog::suggestions(probe_partial, 3).empty()) { + result.details = + std::string("expected suggestions for '") + probe_partial + "'"; return result; } // Multi-file entries (VLM pairs, embeddings) must carry ≥2 required files. - const wally::catalog::CatalogEntry *vlm = wally::catalog::find("smolvlm2"); - if (!vlm || vlm->files == nullptr || vlm->file_count != 2) { - result.details = "smolvlm2 should be a two-file artifact"; - return result; - } - + // smolvlm2 is a VLM, out of scope for the LLM-only cut (src/app.cpp, + // src/catalog/catalog.cpp) -- commented out, not deleted, so it comes back + // when the cut reverts. + // const wally::catalog::CatalogEntry *vlm = wally::catalog::find("smolvlm2"); + // if (!vlm || vlm->files == nullptr || vlm->file_count != 2) { + // result.details = "smolvlm2 should be a two-file artifact"; + // return result; + // } + + // MLX entries are Apple-only; the catalog hides them off Apple, so these + // lookups only resolve (and are only asserted) on Apple. +#if defined(__APPLE__) const wally::catalog::CatalogEntry *mlx_llm = wally::catalog::find("mlx-qwen3"); if (!mlx_llm || mlx_llm->framework != runanywhere::v1::INFERENCE_FRAMEWORK_MLX || @@ -336,7 +397,21 @@ TestResult test_catalog_lookup() { result.details = "mlx-qwen3 should be a complete MLX language bundle"; return result; } +#else + // The inverse of the Apple assertion above: platform_supports() + // (src/catalog/catalog.cpp) hides every MLX row off Apple, so the same id + // must resolve to nothing here. Pins the hiding behavior on every + // non-Apple platform, not just where MLX is visible. + if (wally::catalog::find("mlx-qwen3") != nullptr) { + result.details = "mlx-qwen3 should be hidden off Apple"; + return result; + } +#endif + // maple-preview is a llama.cpp row; kits without that backend (the public + // windows-arm64 kit) hide it, so the pinned-bundle check only applies where + // the row is listed. +#if defined(WALLY_HAS_LLAMACPP) const wally::catalog::CatalogEntry *maple_gguf = wally::catalog::find("maple-preview"); if (!maple_gguf || @@ -347,7 +422,9 @@ TestResult test_catalog_lookup() { result.details = "maple-preview should resolve to the pinned GGUF bundle"; return result; } +#endif +#if defined(__APPLE__) const wally::catalog::CatalogEntry *mlx_maple = wally::catalog::find("mlx-maple-preview"); if (!mlx_maple || @@ -376,7 +453,12 @@ TestResult test_catalog_lookup() { result.details = "mlx-maple-preview file sizes must sum to the bundle size"; return result; } +#endif + // VLM (multimodal) and embedding catalog entries are out of scope for the + // LLM-only cut (src/app.cpp, src/catalog/catalog.cpp) -- commented out, not + // deleted, so this comes back when the cut reverts. + /* const wally::catalog::CatalogEntry *mlx_vlm = wally::catalog::find("mlx-qwen2-vl"); if (!mlx_vlm || @@ -465,7 +547,9 @@ TestResult test_catalog_lookup() { return result; } } + */ +#if defined(__APPLE__) const wally::catalog::CatalogEntry *nemotron_nano = wally::catalog::find("mlx-nemotron-nano"); if (!nemotron_nano || @@ -498,7 +582,12 @@ TestResult test_catalog_lookup() { return result; } } +#endif + // Speech recognition entries are out of scope for the LLM-only cut + // (src/app.cpp, src/catalog/catalog.cpp) -- commented out, not deleted, so + // this comes back when the cut reverts. + /* struct NvidiaSpeechCase { const char *alias; int64_t download_size_bytes; @@ -526,6 +615,7 @@ TestResult test_catalog_lookup() { return result; } } + */ result.passed = true; return result; @@ -541,36 +631,68 @@ TestResult test_overlay_catalog() { runanywhere::v1::ModelCategory category; runanywhere::v1::InferenceFramework framework; }; - const Row rows[] = { - {"sd15", "stable-diffusion-v1-5-coreml", - runanywhere::v1::MODEL_CATEGORY_IMAGE_GENERATION, - runanywhere::v1::INFERENCE_FRAMEWORK_COREML}, - {"lfm2-230m-ane", "lfm2_5_230m_ane", - runanywhere::v1::MODEL_CATEGORY_LANGUAGE, - runanywhere::v1::INFERENCE_FRAMEWORK_COREML}, - {"parakeet-tdt-v2-ane", "parakeet_tdt_0_6b_v2_ane", - runanywhere::v1::MODEL_CATEGORY_SPEECH_RECOGNITION, - runanywhere::v1::INFERENCE_FRAMEWORK_COREML}, + // Every row is conditional, so a kit with no overlay (Windows x64) would + // leave this array zero-size -- a GNU extension MSVC rejects (C2466). Size + // it explicitly with one spare value-initialized slot the loop never reads. + constexpr size_t kRowCount = + // TEMP(ane-cut): the ANE row below is commented out with its catalog + // entries; count it again when they come back. + // #if defined(WALLY_HAS_NEURT) + // 1 + + // #endif +#if defined(WALLY_HAS_QHEXRT) + 1 + +#endif + 0; + const Row rows[kRowCount + 1] = { + // Non-LANGUAGE overlay rows are out of scope for the LLM-only cut + // (src/app.cpp, src/catalog/catalog.cpp) -- commented out, not + // deleted, so they come back when the cut reverts. + // {"sd15", "stable-diffusion-v1-5-coreml", + // runanywhere::v1::MODEL_CATEGORY_IMAGE_GENERATION, + // runanywhere::v1::INFERENCE_FRAMEWORK_COREML}, + // ANE (Core ML) rows exist only in a kit that shipped the NeuRT engine + // (a private overlay pack); the public Apple kit has none, so a Mac + // without it must not list them either. Gated on the kit macro, not on + // __APPLE__, for the same reason as the QHexRT rows below. + // TEMP(ane-cut): the rows themselves are commented out in catalog.cpp + // (repo-page URLs, nothing to download); restore together. + // #if defined(WALLY_HAS_NEURT) + // {"lfm2-230m-ane", "lfm2_5_230m_ane", + // runanywhere::v1::MODEL_CATEGORY_LANGUAGE, + // runanywhere::v1::INFERENCE_FRAMEWORK_COREML}, + // #endif + // {"parakeet-tdt-v2-ane", "parakeet_tdt_0_6b_v2_ane", + // runanywhere::v1::MODEL_CATEGORY_SPEECH_RECOGNITION, + // runanywhere::v1::INFERENCE_FRAMEWORK_COREML}, + // QHexRT rows exist only in a kit that shipped the Hexagon NPU overlay + // (Windows ARM64); platform_supports() hides them everywhere else, the + // same way it hides the ANE rows above off Apple. Gated on the kit macro + // rather than on the OS so this tracks the real matrix -- see + // wally_define_engine_macros() in tests/CMakeLists.txt. +#if defined(WALLY_HAS_QHEXRT) {"lfm2-230m-npu", "lfm2_5_230m", runanywhere::v1::MODEL_CATEGORY_LANGUAGE, runanywhere::v1::INFERENCE_FRAMEWORK_QHEXRT}, - {"whisper-base-npu", "whisper_base", - runanywhere::v1::MODEL_CATEGORY_SPEECH_RECOGNITION, - runanywhere::v1::INFERENCE_FRAMEWORK_QHEXRT}, - {"kitten-micro-npu", "kitten_micro_0_8", - runanywhere::v1::MODEL_CATEGORY_SPEECH_SYNTHESIS, - runanywhere::v1::INFERENCE_FRAMEWORK_QHEXRT}, - {"embeddinggemma-npu", "embeddinggemma_300m", - runanywhere::v1::MODEL_CATEGORY_EMBEDDING, - runanywhere::v1::INFERENCE_FRAMEWORK_QHEXRT}, - {"internvl-1b-npu", "internvl3_5_1b", - runanywhere::v1::MODEL_CATEGORY_MULTIMODAL, - runanywhere::v1::INFERENCE_FRAMEWORK_QHEXRT}, - {"cosmos3-diffusion-npu", "cosmos3_edge_diffusion", - runanywhere::v1::MODEL_CATEGORY_IMAGE_GENERATION, - runanywhere::v1::INFERENCE_FRAMEWORK_QHEXRT}, +#endif + // {"whisper-base-npu", "whisper_base", + // runanywhere::v1::MODEL_CATEGORY_SPEECH_RECOGNITION, + // runanywhere::v1::INFERENCE_FRAMEWORK_QHEXRT}, + // {"kitten-micro-npu", "kitten_micro_0_8", + // runanywhere::v1::MODEL_CATEGORY_SPEECH_SYNTHESIS, + // runanywhere::v1::INFERENCE_FRAMEWORK_QHEXRT}, + // {"embeddinggemma-npu", "embeddinggemma_300m", + // runanywhere::v1::MODEL_CATEGORY_EMBEDDING, + // runanywhere::v1::INFERENCE_FRAMEWORK_QHEXRT}, + // {"internvl-1b-npu", "internvl3_5_1b", + // runanywhere::v1::MODEL_CATEGORY_MULTIMODAL, + // runanywhere::v1::INFERENCE_FRAMEWORK_QHEXRT}, + // {"cosmos3-diffusion-npu", "cosmos3_edge_diffusion", + // runanywhere::v1::MODEL_CATEGORY_IMAGE_GENERATION, + // runanywhere::v1::INFERENCE_FRAMEWORK_QHEXRT}, }; - for (const Row &row : rows) { + for (size_t i = 0; i < kRowCount; ++i) { + const Row &row = rows[i]; const wally::catalog::CatalogEntry *by_alias = wally::catalog::find(row.id); const wally::catalog::CatalogEntry *by_id = wally::catalog::find(row.alias); if (!by_alias || by_alias != by_id || by_alias->category != row.category || @@ -580,6 +702,24 @@ TestResult test_overlay_catalog() { return result; } } + // The other half of the gate: a backend this kit did not ship must not be + // listed or resolvable, or a user would be offered a model this binary can + // never run. Without this, a regression in platform_supports() would only + // show up on the one platform that has the overlay. +#if !defined(WALLY_HAS_QHEXRT) + if (wally::catalog::find("lfm2-230m-npu") != nullptr) { + result.details = "lfm2-230m-npu must be hidden in a kit without QHexRT"; + return result; + } +#endif + // Both spellings: the row's own alias and the `ane-` form the + // `models list` header used to advertise on every Mac. Unconditional while + // the ane-cut is in effect; re-gate on !WALLY_HAS_NEURT when it reverts. + if (wally::catalog::find("lfm2-230m-ane") != nullptr || + wally::catalog::find("ane-lfm2.5-350m") != nullptr) { + result.details = "ANE rows must not be listed (ane-cut)"; + return result; + } result.passed = true; return result; } @@ -588,6 +728,12 @@ TestResult test_nvidia_sherpa_catalog() { TestResult result; result.test_name = "nvidia_sherpa_catalog"; + // Sherpa-ONNX (speech recognition) is out of scope for the LLM-only cut + // (src/app.cpp, src/catalog/catalog.cpp): the whole body below is + // commented out, not deleted, so it comes back when the cut reverts. + result.passed = true; + return result; + /* struct ExpectedFile { const char *filename; int64_t size_bytes; @@ -719,6 +865,7 @@ TestResult test_nvidia_sherpa_catalog() { result.passed = true; return result; + */ } TestResult test_engine_hint_parsing() { @@ -736,8 +883,12 @@ TestResult test_engine_hint_parsing() { {"llama-cpp", runanywhere::v1::INFERENCE_FRAMEWORK_LLAMA_CPP}, {"onnx", runanywhere::v1::INFERENCE_FRAMEWORK_ONNX}, {"sherpa", runanywhere::v1::INFERENCE_FRAMEWORK_SHERPA}, + // The Apple Neural Engine names parse only when the kit linked NeuRT; + // the refusal on every other build is asserted below. +#if defined(WALLY_HAS_NEURT) {"neurt", runanywhere::v1::INFERENCE_FRAMEWORK_COREML}, {"ane", runanywhere::v1::INFERENCE_FRAMEWORK_COREML}, +#endif {"qhexrt", runanywhere::v1::INFERENCE_FRAMEWORK_QHEXRT}, {"npu", runanywhere::v1::INFERENCE_FRAMEWORK_QHEXRT}, }; @@ -762,6 +913,25 @@ TestResult test_engine_hint_parsing() { result.details = "unsupported engine should fail with an actionable error"; return result; } +#if !defined(WALLY_HAS_NEURT) + // `--engine ane` on a build with no NeuRT used to be accepted and then fall + // through to MLX, which failed on a Core ML tree with a misleading + // "config.json not found". It must be refused here, naming the build. + for (const char *name : {"ane", "neurt", "coreml"}) { + error.clear(); + if (wally::commands::parse_engine_hint(name, &actual, &error) || + error.find("not in this build") == std::string::npos) { + result.details = std::string("--engine ") + name + + " must be refused in a kit without NeuRT; got: " + error; + return result; + } + } + // And the help text must not advertise it either. + if (std::string(wally::commands::engine_choices()).find("ane") != std::string::npos) { + result.details = "engine_choices() lists ane in a kit without NeuRT"; + return result; + } +#endif result.passed = true; return result; @@ -814,6 +984,9 @@ TestResult test_mlx_catalog_registration() { result.details = "catalog registration failed rc=" + std::to_string(rc); return result; } + // MLX is an Apple-only backend; off Apple the catalog deliberately does not + // register its MLX rows, so the per-model checks below only run on Apple. +#if defined(__APPLE__) RegisteredModelCleanup cleanup({ "mlx-qwen3-0.6b-4bit", "mlx-maple-preview-2bit", @@ -866,6 +1039,10 @@ TestResult test_mlx_catalog_registration() { return result; } + // VLM, embedding and ASR registrations are out of scope for the LLM-only + // cut (src/app.cpp, src/catalog/catalog.cpp) -- commented out, not + // deleted, so this comes back when the cut reverts. + /* runanywhere::v1::ModelInfo vlm; if (!get_registered_model("mlx-qwen2-vl-2b-instruct-4bit", &vlm, &error)) { result.details = error; @@ -941,6 +1118,7 @@ TestResult test_mlx_catalog_registration() { result.details = "registered MLX GLM-ASR metadata is incomplete"; return result; } + */ struct RegisteredNvidiaCase { const char *id; @@ -950,11 +1128,12 @@ TestResult test_mlx_catalog_registration() { const RegisteredNvidiaCase registered_nvidia_cases[] = { {"mlx-llama-3.1-nemotron-nano-8b-v1-4bit", 8, 4534806075LL}, {"mlx-nemotron-mini-4b-instruct-4bit", 6, 2392679103LL}, - {"mlx-parakeet-ctc-1.1b", 2, 4250718357LL}, - {"mlx-parakeet-tdt-0.6b-v2", 2, 2471596080LL}, - {"mlx-parakeet-tdt-0.6b-v3", 2, 2508532829LL}, - {"mlx-parakeet-rnnt-1.1b", 2, 4282283914LL}, - {"mlx-nemotron-3.5-asr-streaming-0.6b-8bit", 2, 755758528LL}, + // Speech recognition entries, out of scope for the LLM-only cut. + // {"mlx-parakeet-ctc-1.1b", 2, 4250718357LL}, + // {"mlx-parakeet-tdt-0.6b-v2", 2, 2471596080LL}, + // {"mlx-parakeet-tdt-0.6b-v3", 2, 2508532829LL}, + // {"mlx-parakeet-rnnt-1.1b", 2, 4282283914LL}, + // {"mlx-nemotron-3.5-asr-streaming-0.6b-8bit", 2, 755758528LL}, }; for (const RegisteredNvidiaCase &test_case : registered_nvidia_cases) { runanywhere::v1::ModelInfo model; @@ -971,6 +1150,10 @@ TestResult test_mlx_catalog_registration() { } } + // Sherpa-ONNX (speech recognition) and TTS registrations are out of scope + // for the LLM-only cut (src/app.cpp, src/catalog/catalog.cpp) -- + // commented out, not deleted, so this comes back when the cut reverts. + /* struct RegisteredSherpaCase { const char *id; int expected_files; @@ -1080,6 +1263,20 @@ TestResult test_mlx_catalog_registration() { result.details = "registered MLX Soprano metadata is incomplete"; return result; } + */ +#else + // The inverse of the Apple assertions above: register_all() + // (src/catalog/catalog.cpp) skips every MLX row off Apple via + // platform_supports(), so a registry lookup for one must fail here. Pins + // the hiding behavior on every non-Apple platform, not just where MLX + // registration is visible. + runanywhere::v1::ModelInfo mlx_model; + std::string mlx_error; + if (get_registered_model("mlx-qwen3-0.6b-4bit", &mlx_model, &mlx_error)) { + result.details = "mlx-qwen3-0.6b-4bit should not be registered off Apple"; + return result; + } +#endif // defined(__APPLE__) result.passed = true; return result; @@ -1193,6 +1390,12 @@ int run_wally(const std::vector &args) { return wally::run(static_cast(argv.size()), argv.data()); } +// register_diarize() is commented out in src/app.cpp for the LLM-only cut, so +// the subcommand these introspection assertions target does not exist. +// Excluded from the suite (see WALLY_LLM_ONLY_CUT above) rather than kept as +// a body-less `result.passed = true`, which would report a bare, permanent +// green pass. +#if !WALLY_LLM_ONLY_CUT TestResult test_diarize_arg_surface() { TestResult result; result.test_name = "diarize_arg_surface"; @@ -1242,7 +1445,18 @@ TestResult test_diarize_arg_surface() { result.passed = true; return result; } - +#endif // !WALLY_LLM_ONLY_CUT + +// The five exit2 tests below (missing --model, missing audio, non-existent +// audio, non-numeric option, unknown flag) each only assert `exit code == 2`. +// With register_diarize() commented out in src/app.cpp, `wally diarize ...` +// is itself an unrecognized subcommand, which CLI11 also fails via +// ExtrasError -> exit 2 -- before any of the diarize-specific argument +// validation they name is ever reached. Left compiled in, they would stay +// green even if diarize's argument parsing regressed or the command were +// deleted outright, so they are excluded from the suite along with the rest +// of the diarize coverage (see WALLY_LLM_ONLY_CUT above). +#if !WALLY_LLM_ONLY_CUT TestResult test_diarize_missing_model_exit2() { TestResult result; result.test_name = "diarize_missing_model_exit2"; @@ -1346,6 +1560,7 @@ TestResult test_diarize_unknown_flag_exit2() { result.passed = true; return result; } +#endif // !WALLY_LLM_ONLY_CUT // =========================================================================== // image_io helpers (write_png / read_ppm) — the segment command's PNG encoder @@ -2279,6 +2494,12 @@ TestResult test_run_max_tokens_negative_exit2() { return result; } +// Same spurious-pass mechanism as the diarize exit2 tests above (see +// WALLY_LLM_ONLY_CUT): register_rerank() is also commented out in +// src/app.cpp, so `wally rerank ...` is an unrecognized subcommand that fails +// with ExtrasError -> exit 2 before `--top-n`'s own validation ever runs. +// Excluded from the suite rather than left to pass for the wrong reason. +#if !WALLY_LLM_ONLY_CUT TestResult test_rerank_top_n_zero_exit2() { TestResult result; result.test_name = "rerank_top_n_zero_exit2"; @@ -2310,6 +2531,7 @@ TestResult test_rerank_top_n_negative_exit2() { result.passed = true; return result; } +#endif // !WALLY_LLM_ONLY_CUT TestResult test_bench_zero_trials_exit2() { TestResult result; @@ -2326,10 +2548,10 @@ TestResult test_bench_zero_trials_exit2() { return result; } -// CLI11's help banner always names a subcommand's primary registered name, -// never the alias actually typed (App::get_display_name() ignores it), so -// the shorter terminal name has to be the one registered as primary — the -// same call `rm`/`remove` already makes. +// Model verbs live under the `models` namespace only (`wally models +// list|pull|rm|show`); there is no top-level `ls`/`pull`/`show`/`rm` +// shortcut. `list` is the primary registered name there (`ls` is its +// alias), and `run` remains the only top-level model shortcut. TestResult test_models_ls_is_primary_name() { TestResult result; result.test_name = "models_ls_is_primary_name"; @@ -2338,20 +2560,30 @@ TestResult test_models_ls_is_primary_name() { CLI::App app{"wally test app"}; wally::configure_app(app, options); - const CLI::App *ls = app.get_subcommand_no_throw("ls"); - if (ls == nullptr) { - result.details = "ls subcommand not registered"; + if (app.get_subcommand_no_throw("ls") != nullptr) { + result.details = "top-level ls must not be registered; use `models list`"; return result; } - if (ls->get_name() != "ls") { - result.expected = "ls"; - result.actual = ls->get_name(); - result.details = "ls must be the primary name so its own --help banner names itself"; + + const CLI::App *models = app.get_subcommand_no_throw("models"); + if (models == nullptr) { + result.details = "models subcommand not registered"; + return result; + } + const CLI::App *list = models->get_subcommand_no_throw("list"); + if (list == nullptr) { + result.details = "models list not registered"; return result; } - const CLI::App *list = app.get_subcommand_no_throw("list"); - if (list != ls) { - result.details = "list must still resolve to the same subcommand, as an alias"; + if (list->get_name() != "list") { + result.expected = "list"; + result.actual = list->get_name(); + result.details = "list must be the primary name under models"; + return result; + } + const CLI::App *ls = models->get_subcommand_no_throw("ls"); + if (ls != list) { + result.details = "models ls must resolve to the same subcommand, as an alias"; return result; } result.passed = true; @@ -2995,8 +3227,46 @@ TestResult test_ensure_installed_finds_tool_off_path() { #endif } +// `wally backends` reports every registered engine (the e2e assert-backends.sh +// expects llamacpp, onnx and sherpa on every kit); `about` and `info` show +// only the ones that serve generate_text. Filtering the shared collector once +// dropped onnx and sherpa from `backends` and turned Linux and Windows CI red, +// so the two views are pinned against each other here, whatever this build +// happens to have registered. +TestResult test_llm_backend_rows_are_a_generate_text_subset() { + TestResult result; + result.test_name = "llm_backend_rows_are_a_generate_text_subset"; + + const auto all = wally::commands::collect_backend_rows(); + const auto llm = wally::commands::collect_llm_backend_rows(); + const std::string generate_text = rac_primitive_name(RAC_PRIMITIVE_GENERATE_TEXT); + + for (const auto &[name, row] : llm) { + if (all.find(name) == all.end()) { + result.details = name + " is in the LLM view but not the full one"; + return result; + } + if (row.primitives.count(generate_text) == 0) { + result.details = name + " is in the LLM view without serving generate_text"; + return result; + } + } + for (const auto &[name, row] : all) { + const bool serves_llm = row.primitives.count(generate_text) != 0; + if (serves_llm != (llm.find(name) != llm.end())) { + result.details = name + (serves_llm ? " serves generate_text but was filtered out" + : " does not serve generate_text but was kept"); + return result; + } + } + result.passed = true; + return result; +} + int main(int argc, char **argv) { TestSuite suite("wally_unit"); + suite.add("llm_backend_rows_are_a_generate_text_subset", + test_llm_backend_rows_are_a_generate_text_subset); suite.add("json_escape", test_json_escape); suite.add("json_writer_shape", test_json_writer_shape); suite.add("json_writer_nan_is_null", test_json_writer_nan_is_null); @@ -3010,6 +3280,10 @@ int main(int argc, char **argv) { suite.add("engine_hint_parsing", test_engine_hint_parsing); suite.add("mlx_catalog_registration", test_mlx_catalog_registration); suite.add("hf_ref_registration", test_hf_ref_registration); + // diarize coverage is unregistered under the LLM-only cut (see + // WALLY_LLM_ONLY_CUT above the includes) -- the functions themselves are + // not compiled in that configuration, so they cannot be registered either. +#if !WALLY_LLM_ONLY_CUT suite.add("diarize_arg_surface", test_diarize_arg_surface); suite.add("diarize_missing_model_exit2", test_diarize_missing_model_exit2); suite.add("diarize_missing_audio_exit2", test_diarize_missing_audio_exit2); @@ -3017,6 +3291,7 @@ int main(int argc, char **argv) { suite.add("diarize_numeric_option_typing_exit2", test_diarize_numeric_option_typing_exit2); suite.add("diarize_unknown_flag_exit2", test_diarize_unknown_flag_exit2); +#endif // !WALLY_LLM_ONLY_CUT suite.add("read_ppm_errors", test_read_ppm_errors); suite.add("read_ppm_happy_path", test_read_ppm_happy_path); suite.add("read_ppm_header_lexing", test_read_ppm_header_lexing); @@ -3028,8 +3303,12 @@ int main(int argc, char **argv) { suite.add("bench_metrics_consume_only", test_bench_metrics_consume_only); suite.add("run_max_tokens_zero_exit2", test_run_max_tokens_zero_exit2); suite.add("run_max_tokens_negative_exit2", test_run_max_tokens_negative_exit2); + // rerank coverage is unregistered under the LLM-only cut, same as diarize + // above (see WALLY_LLM_ONLY_CUT). +#if !WALLY_LLM_ONLY_CUT suite.add("rerank_top_n_zero_exit2", test_rerank_top_n_zero_exit2); suite.add("rerank_top_n_negative_exit2", test_rerank_top_n_negative_exit2); +#endif // !WALLY_LLM_ONLY_CUT suite.add("bench_negative_trials_exit2", test_bench_negative_trials_exit2); suite.add("bench_zero_trials_exit2", test_bench_zero_trials_exit2); suite.add("models_ls_is_primary_name", test_models_ls_is_primary_name); From 0fe2472fda5f34f9a16d39a25e789efa7f8061b2 Mon Sep 17 00:00:00 2001 From: Sanchit Monga Date: Mon, 21 Sep 2026 09:09:27 -0700 Subject: [PATCH 3/4] release: wally 0.6.0 Bump the product version for the LLM-only command surface and stamp the Homebrew formula to match, including caveats that no longer point at unregistered verbs. Co-authored-by: Cursor --- Formula/wally.rb | 17 ++++++++--------- versions.toml | 2 +- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/Formula/wally.rb b/Formula/wally.rb index 6968b5bd..8a4324de 100644 --- a/Formula/wally.rb +++ b/Formula/wally.rb @@ -3,15 +3,15 @@ class Wally < Formula desc "Run language, speech and image models on your own machine" homepage "https://github.com/RunanywhereAI/wally" - version "0.5.10" + version "0.6.0" license "MIT" # macOS arm64 ships the Swift MLX host # (llama.cpp + ONNX + Sherpa + MLX). Linux is not in this cut. on_macos do on_arm do - url "https://github.com/RunanywhereAI/wally/releases/download/v0.5.10/wally-0.5.10-macos-arm64.tar.gz" - # Placeholder -- the v0.5.10 release has not published a wally-named asset + url "https://github.com/RunanywhereAI/wally/releases/download/v0.6.0/wally-0.6.0-macos-arm64.tar.gz" + # Placeholder -- the v0.6.0 release has not published a wally-named asset # yet. scripts/release/update-tap.sh re-stamps this from the real release # checksum; a stale value here fails brew install's own hash check # rather than installing something unverified. @@ -31,14 +31,13 @@ def caveats ~/.local/share/runanywhere Getting started: - wally list downloaded models - wally pull qwen3-0.6b download one - wally run qwen3-0.6b talk to it - wally run qwen3-0.6b "hi" ask once and exit + wally models list downloaded models + wally models pull qwen3 download one + wally run qwen3 talk to it + wally run qwen3 "hi" ask once and exit Also: - wally tts "hello" speak text - wally stt recording.wav transcribe audio + wally account login sign in wally backends which engines this build linked EOS end diff --git a/versions.toml b/versions.toml index 7a7351e4..90696ad7 100644 --- a/versions.toml +++ b/versions.toml @@ -7,7 +7,7 @@ [product] # wally's own release version; git tag is v (auto-tag.yml reads it here). -version = "0.5.10" +version = "0.6.0" [sdk] # The published C++ desktop kit this tree builds against; bump the SHAs with it. From 0df3166e317e3976346396cc6e5e5db38069e569 Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Tue, 22 Sep 2026 13:25:30 +0530 Subject: [PATCH 4/4] release: remove dev ci-cd release and nightly variants --- .github/workflows/release.yml | 86 +++++++++--------------- install.sh | 18 ++--- scripts/release/verify-release-assets.py | 5 +- scripts/test/test-install-cross-shell.sh | 21 +++++- tests/test_release_assets.py | 10 +-- 5 files changed, 62 insertions(+), 78 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 66657b85..505a23ab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,13 +17,10 @@ permissions: env: WALLY_VERSION: ${{ github.event.inputs.version || github.ref_name }} -# Every platform builds two bottles from one kit: `prod` (empty bake -> the -# production console defaults in credentials.cpp) and `dev` (dev console -# endpoints baked from the WALLY_DEV_* repo variables, so a dev build targets -# the dev backend with no env vars). The bake reaches the configure step as -# environment only — never on the command line, never in committed source. The -# configure→build→package sequence lives in .github/actions/build-wally; e2e -# runs on the binary it produced, so the bottle is built exactly once. +# Every platform builds one bottle from one kit: `prod` (empty bake -> the +# production console defaults in credentials.cpp). The configure→build→package +# sequence lives in .github/actions/build-wally; e2e runs on the binary it +# produced, so the bottle is built exactly once. jobs: macos: # SDK Package.swift is swift-tools-version 6.2 (Xcode 26). @@ -32,11 +29,9 @@ jobs: strategy: fail-fast: false matrix: - variant: [prod, dev] + variant: [prod] env: WALLY_SDK_SWIFT_PATH: ${{ github.workspace }}/.deps/runanywhere-sdks - WALLY_BAKED_CONSOLE_API_URL: ${{ matrix.variant == 'dev' && vars.WALLY_DEV_CONSOLE_API_URL || '' }} - WALLY_BAKED_CONSOLE_WEB_ORIGIN: ${{ matrix.variant == 'dev' && vars.WALLY_DEV_CONSOLE_WEB_ORIGIN || '' }} steps: - uses: actions/checkout@v4 with: @@ -79,10 +74,9 @@ jobs: - name: Verify archive run: | VERSION="${WALLY_VERSION#v}" - suffix=""; [ "${{ matrix.variant }}" = dev ] && suffix="-dev" python3 scripts/release/verify-release-assets.py \ - "dist/wally-${VERSION}-macos-arm64${suffix}.tar.gz" \ - "dist/wally-${VERSION}-macos-arm64${suffix}.tar.gz.sha256" + "dist/wally-${VERSION}-macos-arm64.tar.gz" \ + "dist/wally-${VERSION}-macos-arm64.tar.gz.sha256" - uses: actions/upload-artifact@v4 with: name: wally-macos-arm64-${{ matrix.variant }} @@ -94,10 +88,7 @@ jobs: strategy: fail-fast: false matrix: - variant: [prod, dev] - env: - WALLY_BAKED_CONSOLE_API_URL: ${{ matrix.variant == 'dev' && vars.WALLY_DEV_CONSOLE_API_URL || '' }} - WALLY_BAKED_CONSOLE_WEB_ORIGIN: ${{ matrix.variant == 'dev' && vars.WALLY_DEV_CONSOLE_WEB_ORIGIN || '' }} + variant: [prod] steps: - uses: actions/checkout@v4 with: @@ -139,16 +130,14 @@ jobs: shell: pwsh run: | $ver = "${env:WALLY_VERSION}".TrimStart("v") - $suffix = if ("${{ matrix.variant }}" -eq "dev") { "-dev" } else { "" } - scripts/test/smoke-zip-windows.ps1 -Zip "dist/wally-$ver-windows-arm64$suffix.zip" + scripts/test/smoke-zip-windows.ps1 -Zip "dist/wally-$ver-windows-arm64.zip" - name: Verify archive shell: bash run: | ver="${WALLY_VERSION#v}" - suffix=""; [ "${{ matrix.variant }}" = dev ] && suffix="-dev" python scripts/release/verify-release-assets.py \ - "dist/wally-$ver-windows-arm64$suffix.zip" \ - "dist/wally-$ver-windows-arm64$suffix.zip.sha256" + "dist/wally-$ver-windows-arm64.zip" \ + "dist/wally-$ver-windows-arm64.zip.sha256" - uses: actions/upload-artifact@v4 with: name: wally-windows-arm64-${{ matrix.variant }} @@ -163,10 +152,7 @@ jobs: strategy: fail-fast: false matrix: - variant: [prod, dev] - env: - WALLY_BAKED_CONSOLE_API_URL: ${{ matrix.variant == 'dev' && vars.WALLY_DEV_CONSOLE_API_URL || '' }} - WALLY_BAKED_CONSOLE_WEB_ORIGIN: ${{ matrix.variant == 'dev' && vars.WALLY_DEV_CONSOLE_WEB_ORIGIN || '' }} + variant: [prod] steps: - uses: actions/checkout@v4 with: @@ -206,16 +192,14 @@ jobs: shell: pwsh run: | $ver = "${env:WALLY_VERSION}".TrimStart("v") - $suffix = if ("${{ matrix.variant }}" -eq "dev") { "-dev" } else { "" } - scripts/test/smoke-zip-windows.ps1 -Zip "dist/wally-$ver-windows-x86_64$suffix.zip" + scripts/test/smoke-zip-windows.ps1 -Zip "dist/wally-$ver-windows-x86_64.zip" - name: Verify archive shell: bash run: | ver="${WALLY_VERSION#v}" - suffix=""; [ "${{ matrix.variant }}" = dev ] && suffix="-dev" python scripts/release/verify-release-assets.py \ - "dist/wally-$ver-windows-x86_64$suffix.zip" \ - "dist/wally-$ver-windows-x86_64$suffix.zip.sha256" + "dist/wally-$ver-windows-x86_64.zip" \ + "dist/wally-$ver-windows-x86_64.zip.sha256" - uses: actions/upload-artifact@v4 with: name: wally-windows-x64-${{ matrix.variant }} @@ -230,10 +214,7 @@ jobs: strategy: fail-fast: false matrix: - variant: [prod, dev] - env: - WALLY_BAKED_CONSOLE_API_URL: ${{ matrix.variant == 'dev' && vars.WALLY_DEV_CONSOLE_API_URL || '' }} - WALLY_BAKED_CONSOLE_WEB_ORIGIN: ${{ matrix.variant == 'dev' && vars.WALLY_DEV_CONSOLE_WEB_ORIGIN || '' }} + variant: [prod] steps: - uses: actions/checkout@v4 with: @@ -271,10 +252,9 @@ jobs: - name: Verify archive run: | VERSION="${WALLY_VERSION#v}" - suffix=""; [ "${{ matrix.variant }}" = dev ] && suffix="-dev" python3 scripts/release/verify-release-assets.py \ - "dist/wally-${VERSION}-linux-x86_64${suffix}.tar.gz" \ - "dist/wally-${VERSION}-linux-x86_64${suffix}.tar.gz.sha256" + "dist/wally-${VERSION}-linux-x86_64.tar.gz" \ + "dist/wally-${VERSION}-linux-x86_64.tar.gz.sha256" - uses: actions/upload-artifact@v4 with: name: wally-linux-x86_64-${{ matrix.variant }} @@ -295,26 +275,24 @@ jobs: run: | set -euo pipefail VERSION="${WALLY_VERSION#v}" - # Both bottles of every platform: prod (no suffix) and dev (-dev). - for suffix in "" "-dev"; do - python3 scripts/release/verify-release-assets.py \ - "artifacts/wally-${VERSION}-macos-arm64${suffix}.tar.gz" \ - "artifacts/wally-${VERSION}-macos-arm64${suffix}.tar.gz.sha256" - python3 scripts/release/verify-release-assets.py \ - "artifacts/wally-${VERSION}-windows-x86_64${suffix}.zip" \ - "artifacts/wally-${VERSION}-windows-x86_64${suffix}.zip.sha256" - python3 scripts/release/verify-release-assets.py \ - "artifacts/wally-${VERSION}-windows-arm64${suffix}.zip" \ - "artifacts/wally-${VERSION}-windows-arm64${suffix}.zip.sha256" - python3 scripts/release/verify-release-assets.py \ - "artifacts/wally-${VERSION}-linux-x86_64${suffix}.tar.gz" \ - "artifacts/wally-${VERSION}-linux-x86_64${suffix}.tar.gz.sha256" - done + # One bottle per platform. + python3 scripts/release/verify-release-assets.py \ + "artifacts/wally-${VERSION}-macos-arm64.tar.gz" \ + "artifacts/wally-${VERSION}-macos-arm64.tar.gz.sha256" + python3 scripts/release/verify-release-assets.py \ + "artifacts/wally-${VERSION}-windows-x86_64.zip" \ + "artifacts/wally-${VERSION}-windows-x86_64.zip.sha256" + python3 scripts/release/verify-release-assets.py \ + "artifacts/wally-${VERSION}-windows-arm64.zip" \ + "artifacts/wally-${VERSION}-windows-arm64.zip.sha256" + python3 scripts/release/verify-release-assets.py \ + "artifacts/wally-${VERSION}-linux-x86_64.tar.gz" \ + "artifacts/wally-${VERSION}-linux-x86_64.tar.gz.sha256" - name: Generate Homebrew formula update run: | set -euo pipefail VERSION="${WALLY_VERSION#v}" - # Homebrew ships the production bottle; the dev bottle is not tapped. + # Homebrew ships the production bottle. sidecar="artifacts/wally-${VERSION}-macos-arm64.tar.gz.sha256" digest=$(awk 'NF == 2 { print $1 }' "$sidecar") [[ "$digest" =~ ^[0-9a-f]{64}$ ]] diff --git a/install.sh b/install.sh index 2d5048c5..945e557b 100755 --- a/install.sh +++ b/install.sh @@ -11,12 +11,7 @@ set -eu # that finds them, so a plain extract-and-symlink keeps every engine working. # # Usage: -# curl -fsSL | sh # production build -# curl -fsSL | sh -s -- nightly # nightly (dev-endpoint) build -# -# `nightly` (or --nightly) installs the -dev bottle, which is baked to talk to -# the development console and APIs. Same binary otherwise; it only changes which -# backend it points at, so it does not disturb the production install path. +# curl -fsSL | sh REPO="RunanywhereAI/wally" LIB_DIR="${HOME}/.local/lib/wally" BIN_DIR="${HOME}/.local/bin" @@ -58,13 +53,12 @@ skill_target_dirs() { } # --- arguments -------------------------------------------------------------- -NIGHTLY=0 # The version the caller already has, passed by `wally update` so the script can # tell it apart from a fresh install and skip the download when nothing is newer. CURRENT_VERSION="" for arg in "$@"; do case "$arg" in - nightly|--nightly) NIGHTLY=1 ;; + nightly|--nightly) fail "nightly/dev installs are no longer published; this installer only supports production releases" ;; --version=*) CURRENT_VERSION="${arg#--version=}" ;; # Debug-only: print the resolved skill targets and exit before any # network work. Exercised by scripts/test/test-install-skill-dirs.sh. @@ -72,11 +66,7 @@ for arg in "$@"; do esac done -if [ "$NIGHTLY" = 1 ]; then - SUFFIX="-dev"; CHANNEL="nightly (development endpoints)" -else - SUFFIX=""; CHANNEL="production" -fi +CHANNEL="production" banner printf ' %sInstalling the %s%s%s build%s\n\n' "$DIM" "$R$B" "$CHANNEL" "$R$DIM" "$R" @@ -116,7 +106,7 @@ case "${os}/${arch}" in esac ok "${PLATFORM}" -ASSET="wally-${VERSION}-${PLATFORM}${SUFFIX}.tar.gz" +ASSET="wally-${VERSION}-${PLATFORM}.tar.gz" URL="https://github.com/${REPO}/releases/download/v${VERSION}/${ASSET}" tmp=$(mktemp -d) diff --git a/scripts/release/verify-release-assets.py b/scripts/release/verify-release-assets.py index 0b1f9b7d..931df017 100755 --- a/scripts/release/verify-release-assets.py +++ b/scripts/release/verify-release-assets.py @@ -12,13 +12,10 @@ import zipfile -# The dev bottle differs only in an added `-dev` before the extension; its -# staged root is still wally- (package scripts keep them identical so -# install extracts both the same), so `platform` must exclude the channel. ASSET = re.compile( r"^wally-(?P[0-9]+\.[0-9]+\.[0-9]+)-" r"(?Pmacos-arm64|linux-x86_64|windows-x86_64|windows-arm64)" - r"(?P-dev)?\.(?Ptar\.gz|zip)$" + r"\.(?Ptar\.gz|zip)$" ) MAX_MEMBERS = 100_000 MAX_UNCOMPRESSED_BYTES = 4 * 1024 * 1024 * 1024 diff --git a/scripts/test/test-install-cross-shell.sh b/scripts/test/test-install-cross-shell.sh index 0f28842b..3fa6e6bb 100755 --- a/scripts/test/test-install-cross-shell.sh +++ b/scripts/test/test-install-cross-shell.sh @@ -127,7 +127,7 @@ run_case() { } for case_name in happy-path unsupported-platform bad-checksum failed-release-lookup \ - update-already-latest update-available; do + update-already-latest update-available nightly-rejected; do extra="" case "$case_name" in happy-path) stub="$GOOD"; os="Darwin"; arch="arm64" ;; @@ -138,6 +138,8 @@ for case_name in happy-path unsupported-platform bad-checksum failed-release-loo update-already-latest) stub="$GOOD"; os="Darwin"; arch="arm64"; extra="--version=1.2.3" ;; # `wally update` from an older build: proceeds to the full install. update-available) stub="$GOOD"; os="Darwin"; arch="arm64"; extra="--version=1.0.0" ;; + # nightly/--nightly must fail clearly, not silently fall back to prod. + nightly-rejected) stub="$GOOD"; os="Darwin"; arch="arm64"; extra="nightly" ;; esac bash_out="$(run_case bash "$stub" "$os" "$arch" "$extra")" dash_out="$(run_case dash "$stub" "$os" "$arch" "$extra")" @@ -147,4 +149,21 @@ for case_name in happy-path unsupported-platform bad-checksum failed-release-loo done [ "$fails" -eq 0 ] || { printf '%d comparison(s) failed\n' "$fails" >&2; exit 1; } + +# Extra structural check: nightly-rejected must exit non-zero and mention "error" +nightly_out="$(run_case bash "$GOOD" Darwin arm64 nightly)" +nightly_exit="$(printf '%s\n' "$nightly_out" | head -1)" +nightly_body="$(printf '%s\n' "$nightly_out" | tail -n +2)" +if [ "$nightly_exit" = "1" ]; then + printf 'ok nightly-rejected: exits 1\n' +else + printf 'FAIL nightly-rejected: expected exit 1, got %s\n' "$nightly_exit" + fails=$((fails + 1)) +fi +case "$nightly_body" in + *error:*) printf 'ok nightly-rejected: error message printed\n' ;; + *) printf 'FAIL nightly-rejected: no "error:" in output: %s\n' "$nightly_body"; fails=$((fails + 1)) ;; +esac + +[ "$fails" -eq 0 ] || { printf '%d check(s) failed\n' "$fails" >&2; exit 1; } printf 'all cross-shell cases byte-identical\n' diff --git a/tests/test_release_assets.py b/tests/test_release_assets.py index d3c17b8f..1c8ca701 100644 --- a/tests/test_release_assets.py +++ b/tests/test_release_assets.py @@ -60,16 +60,16 @@ def test_valid_windows_arm64_archive(self) -> None: bundle.writestr("wally-windows-arm64/bin/wally.exe", b"binary") VERIFY.verify(archive, self.sidecar(archive)) - def test_valid_dev_bottle_shares_the_platform_root(self) -> None: - # The -dev bottle only adds -dev to the filename; its staged root stays - # wally-. This is the exact asset name the release verify - # rejected before the channel group was added. + def test_rejects_dev_bottle_name(self) -> None: + # The -dev suffix is no longer published; the verifier must reject it + # so a stale dev artifact cannot accidentally land in a production release. with tempfile.TemporaryDirectory() as temporary: archive = pathlib.Path(temporary) / "wally-0.5.3-macos-arm64-dev.tar.gz" with tarfile.open(archive, "w:gz") as bundle: self.add_tar_file(bundle, "wally-macos-arm64/README.md", b"readme", 0o644) self.add_tar_file(bundle, "wally-macos-arm64/bin/wally", b"binary", 0o755) - VERIFY.verify(archive, self.sidecar(archive)) + with self.assertRaisesRegex(VERIFY.VerificationError, "unsupported release asset name"): + VERIFY.verify(archive, self.sidecar(archive)) def test_valid_windows_archive_with_backslash_members(self) -> None: with tempfile.TemporaryDirectory() as temporary: