diff --git a/docs/ENGINES.md b/docs/ENGINES.md index 2e22fbaa..2430ace6 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -9,7 +9,7 @@ One `wally` binary. **Catalog models already name their engine** (GGUF → llama Override only when you mean it: ```bash -wally llm generate --engine mlx -m mlx-qwen3 "Hello" +wally run --engine mlx mlx-qwen3 "Hello" wally run --engine qhexrt /path/to/lfm2_5_230m_HNPU "Hello" ``` @@ -41,21 +41,21 @@ Public Windows ARM64 kits are commons-only (no llama.cpp / ONNX / Sherpa on MSVC ### Modalities × engines -Yes = this engine implements the primitive. Try = a catalog id that `wally pull` / a local path can run. Overlay engines still need the matching **on-disk bundle** (compiled `.mlmodelc` tree, or `*_HNPU` / `v81/` QNN-context dir) — a Hugging Face *repo page* is HTML, not a model. +Yes = this engine implements the primitive. Try = a catalog id that `wally models pull` / a local path can run. Overlay engines still need the matching **on-disk bundle** (compiled `.mlmodelc` tree, or `*_HNPU` / `v81/` QNN-context dir) — a Hugging Face *repo page* is HTML, not a model. | 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` | +| LLM | `wally run` | yes · `smollm2`, `qwen3` | yes · `mlx-qwen3` | — | — | yes · `lfm2-230m-ane` local Core ML tree | yes · `lfm2-230m-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. +This release ships language models only. The speech, image and embedding commands are not in the CLI yet, so the table has one row. QHexRT on device also needs QAIRT matching the Hexagon skel (`QNN_SDK_ROOT` + `ADSP_LIBRARY_PATH=…\lib\hexagon-v81\unsigned` on v81). Overlay 2.47 DLLs vs a 2.41/2.48 device skel will fail to instantiate graphs. Pass the `*_HNPU` directory, not a GGUF. GGUF files cannot run on the ARM64 overlay binary (no llama.cpp). ## Platforms -**macOS Apple Silicon** (public bottle): llama.cpp + MLX + Sherpa + ONNX. Pull `qwen3` (GGUF) or `mlx-qwen3` (GPU). Image generation is NeuRT (`sd15`) and only works after the private overlay is linked into product `wally`. +**macOS Apple Silicon** (public bottle): llama.cpp + MLX + Sherpa + ONNX. Pull `qwen3` (GGUF) or `mlx-qwen3` (GPU). **Windows x64** (public zip): GGUF / ONNX / Sherpa. No MLX, no NeuRT, no QHexRT. @@ -65,7 +65,7 @@ QHexRT on device also needs QAIRT matching the Hexagon skel (`QNN_SDK_ROOT` + `A **Windows ARM64** (Snapdragon): public kit has no llama.cpp/ONNX/Sherpa. The QHexRT overlay runs Hexagon NPU models from a local `*_HNPU` tree. Do not expect `mlx-*`, GGUF, or `sd15` on that binary. -`wally serve` is macOS and Linux. +`wally serve` is in every build and serves any model the binary has an engine for. Device round-trips are **by modality**, not by engine. `scripts/test/e2e.sh` always runs `scripts/test/e2e-modalities.sh`; public CI leaves the knobs unset and skips. diff --git a/src/app.rs b/src/app.rs index 7ae463e3..1a23bf6b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -304,7 +304,7 @@ pub fn run(args: &[String]) -> i32 { // This shortcut is exactly one bare arg (args.len() == 2 above), // so it can never carry a `--home` override; an empty string // preserves the shortcut's existing default-home behavior. - let code = crate::commands::run_uninstall(false, ""); + let code = crate::commands::run_uninstall(false, false, ""); bootstrap::shutdown(); return code; } diff --git a/src/commands/cmd_maintenance.rs b/src/commands/cmd_maintenance.rs index 41ec06e1..078077e6 100644 --- a/src/commands/cmd_maintenance.rs +++ b/src/commands/cmd_maintenance.rs @@ -324,22 +324,14 @@ fn unix_exe_targets(exe: &str) -> Vec { /// `home_override` is the global `--home` flag, so uninstall removes the same /// model store the other model commands are pointed at, not just the default /// one. -pub fn run_uninstall(yes: bool, home_override: &str) -> i32 { +/// +/// Downloaded models are kept unless the person says otherwise: the store is +/// shared with other RunAnywhere apps, and it is the one thing here that is +/// slow to get back. Interactively they are asked separately; under `--yes` +/// only `--delete-models` removes them. +pub fn run_uninstall(yes: bool, delete_models: bool, home_override: &str) -> i32 { let mut targets: Vec = Vec::new(); - // Windows cannot delete the program file backing its own running - // process (unlike a Unix inode, which stays live under an unlinked - // name until the process exits) -- see the note below, where this is - // reported instead of attempted. - #[cfg(windows)] - let mut manual_removal: Option = None; - let models = models_directory(home_override); - if !models.is_empty() { - targets.push(Target { - label: "models", - path: PathBuf::from(models), - }); - } let config = credentials::profile_directory(); if !config.is_empty() { targets.push(Target { @@ -348,25 +340,20 @@ pub fn run_uninstall(yes: bool, home_override: &str) -> i32 { }); } + // Windows cannot delete the program files backing its own running + // process, so the install folder is handed to a helper that removes it + // once this process has exited (see remove_after_exit). + #[cfg(windows)] + let mut windows_install: Option = None; let exe = self_executable(); if !exe.is_empty() { - // A running exe cannot be deleted on Windows. One that install.ps1 - // put down is named for the person to remove; any other (a source - // build) is left alone, as the C++ build always did there. #[cfg(windows)] { - if let Some(dir) = windows_install_directory(&exe) { - manual_removal = Some(dir); - } + windows_install = windows_install_directory(&exe); } #[cfg(not(windows))] { targets.extend(unix_exe_targets(&exe)); - // Only install.sh's own tree is ever deleted (unix_exe_targets - // above); a Homebrew install or an unverified (e.g. source-build) - // location is reported instead, the same way the Windows branch - // only reports manual_removal rather than deleting an unverified - // binary. if install_sh_layout(&exe).is_none() { if crate::commands::cmd_update::is_homebrew_managed(&exe) { out::status_line( @@ -383,54 +370,58 @@ pub fn run_uninstall(yes: bool, home_override: &str) -> i32 { } let present: Vec = targets.into_iter().filter(|t| t.path.exists()).collect(); + let models = models_directory(home_override); + let models = (!models.is_empty()) + .then(|| PathBuf::from(models)) + .filter(|path| path.exists()); #[cfg(windows)] - let has_manual_removal = manual_removal.is_some(); + let has_install = windows_install.is_some(); #[cfg(not(windows))] - let has_manual_removal = false; + let has_install = false; - if present.is_empty() && !has_manual_removal { + if present.is_empty() && !has_install && models.is_none() { out::status_line("nothing to uninstall; wally is already gone."); return 0; } - if !present.is_empty() { + // Models are listed with everything else only when --delete-models + // already chose them; otherwise they get a question of their own. + let models_listed = models.as_ref().filter(|_| delete_models); + let models_asked = models.as_ref().filter(|_| !delete_models); + let has_listed = !present.is_empty() || has_install || models_listed.is_some(); + if has_listed { out::status_line("wally uninstall will delete:"); for target in &present { - let size = human_size(&target.path); - let size_suffix = if size.is_empty() { - String::new() - } else { - format!(" ({size})") - }; out::status_line(&format!( - " {} {}{size_suffix}", + " {} {}{}", target.label, - target.path.display() + target.path.display(), + size_suffix(&target.path) + )); + } + #[cfg(windows)] + if let Some(dir) = &windows_install { + out::status_line(&format!(" install {}{}", dir.display(), size_suffix(dir))); + } + if let Some(models) = models_listed { + out::status_line(&format!( + " models {}{}", + display_path(models), + size_suffix(models) )); } - } - #[cfg(windows)] - if let Some(dir) = &manual_removal { - // Windows will not let a running process delete (or even rename) its - // own program file, so there is no safe way to have this process - // finish that part of the job itself. Spawning a detached helper - // that waits on our pid and deletes behind us is the other option - // the C++ never had either, but it trades one guaranteed, honest - // message for a background process that can be killed, blocked by - // antivirus, or race a relaunch of wally -- worse failure modes than - // telling the person the one folder left to remove by hand. - out::status_line(&format!( - "wally cannot delete its own running program on Windows; close this window, \ - then delete {} yourself to finish uninstalling.", - dir.display() - )); } out::status_line("your coding tools (claude-code, opencode, ...) are left untouched."); - if !present.is_empty() && !yes { - // Never delete without a real confirmation. A non-interactive shell - // (piped or redirected stdin) cannot answer, so it must pass --yes on - // purpose rather than have the prompt silently skipped. + let mut remove_models = models_listed.is_some(); + if yes { + if let Some(models) = models_asked { + out::status_line(&format!( + "kept your downloaded models in {} (pass --delete-models to delete them)", + display_path(models) + )); + } + } else { if !term::stdin_is_tty() { out::error_line( "uninstall needs a terminal to confirm; re-run with --yes to delete \ @@ -438,10 +429,18 @@ pub fn run_uninstall(yes: bool, home_override: &str) -> i32 { ); return 1; } - if !confirm("delete all of the above?") { + if has_listed && !confirm("delete all of the above?") { out::status_line("aborted; nothing was deleted."); return 1; } + if let Some(models) = models_asked { + out::status_line(&format!( + "\nyour downloaded models: {}{}", + display_path(models), + size_suffix(models) + )); + remove_models = confirm("delete them too?"); + } } // Everything but the running binary (or the tree containing it) first; @@ -451,37 +450,43 @@ pub fn run_uninstall(yes: bool, home_override: &str) -> i32 { // tree out from under it. let mut failures = 0; let mut deferred: Option = None; - for target in &present { - if target.label == "install" { - deferred = Some(target.path.clone()); - continue; + let mut removals: Vec<&Path> = present.iter().map(|t| t.path.as_path()).collect(); + if remove_models { + if let Some(models) = &models { + removals.push(models.as_path()); } - if let Err(e) = remove_all(&target.path) { - out::error_line(&format!( - "could not delete {}: {}", - target.path.display(), - os_error_message(&e) - )); - failures += 1; + } + for path in removals { + if present + .iter() + .any(|t| t.label == "install" && t.path == path) + { + deferred = Some(path.to_path_buf()); + continue; } + failures += report_removal(path); } if let Some(deferred) = deferred { - if let Err(e) = remove_all(&deferred) { + failures += report_removal(&deferred); + } + #[cfg(windows)] + if let Some(dir) = &windows_install { + if remove_after_exit(dir) { + out::status_line(&format!( + "{} is removed as soon as this command exits.", + dir.display() + )); + } else { out::error_line(&format!( - "could not delete {}: {}", - deferred.display(), - os_error_message(&e) + "could not schedule removal of {}; delete it yourself after closing wally.", + dir.display() )); failures += 1; } } + // Reported above already; counted here so a script checking the exit // code sees an incomplete uninstall rather than a clean 0. - #[cfg(windows)] - if has_manual_removal { - failures += 1; - } - if failures != 0 { return 1; } @@ -489,6 +494,77 @@ pub fn run_uninstall(yes: bool, home_override: &str) -> i32 { 0 } +/// `path` with one separator style: the models directory is built with `/` +/// while the Windows paths around it use `\`. +fn display_path(path: &Path) -> String { + let text = path.display().to_string(); + if cfg!(windows) { + text.replace('/', "\\") + } else { + text + } +} + +fn size_suffix(path: &Path) -> String { + let size = human_size(path); + if size.is_empty() { + String::new() + } else { + format!(" ({size})") + } +} + +/// Deletes `path`, printing why when it cannot. Returns the failure count. +fn report_removal(path: &Path) -> i32 { + match remove_all(path) { + Ok(()) => 0, + Err(e) => { + out::error_line(&format!( + "could not delete {}: {}", + path.display(), + os_error_message(&e) + )); + 1 + } + } +} + +/// Starts a hidden PowerShell that waits for this process to exit, deletes +/// `dir`, and drops it from the user's PATH (install.ps1 added it there). The +/// helper is a separate process because Windows will not delete the files of +/// a running program. The paths reach PowerShell as environment variables, +/// never spliced into the command. +#[cfg(windows)] +fn remove_after_exit(dir: &Path) -> bool { + use std::os::windows::process::CommandExt; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + let script = r#"Wait-Process -Id $env:WALLY_UNINSTALL_PID -ErrorAction SilentlyContinue +for ($i = 0; $i -lt 20 -and (Test-Path -LiteralPath $env:WALLY_UNINSTALL_DIR); $i++) { + Remove-Item -LiteralPath $env:WALLY_UNINSTALL_DIR -Recurse -Force -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $env:WALLY_UNINSTALL_DIR) { Start-Sleep -Milliseconds 250 } +} +$entries = @([Environment]::GetEnvironmentVariable('Path', 'User') -split ';' | Where-Object { $_ -and $_ -ne $env:WALLY_UNINSTALL_DIR }) +[Environment]::SetEnvironmentVariable('Path', ($entries -join ';'), 'User')"#; + std::process::Command::new("powershell.exe") + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + ]) + .arg(script) + .env("WALLY_UNINSTALL_PID", std::process::id().to_string()) + .env("WALLY_UNINSTALL_DIR", dir) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .creation_flags(CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW) + .spawn() + .is_ok() +} + /// `wally help [command]`. Renders the same text `app.get_subcommand(topic) /// ->help(app.get_name())` would in C++ for a known `topic`; with no topic, /// or an unknown one, C++ falls back to `app.help()` -- which CLI11 @@ -544,7 +620,17 @@ pub fn register_help(app: &mut App) -> Rc>> { pub fn register_uninstall(app: &mut App) { let cmd = app.add_subcommand("uninstall", "Remove wally, its models and its config"); cmd.add_flag("-y,--yes", "Skip the confirmation prompt"); - cmd.callback(|parsed, options| run_uninstall(parsed.flag("--yes"), &options.home_override)); + cmd.add_flag( + "--delete-models", + "Also delete downloaded models (kept by default)", + ); + cmd.callback(|parsed, options| { + run_uninstall( + parsed.flag("--yes"), + parsed.flag("--delete-models"), + &options.home_override, + ) + }); } #[cfg(test)] diff --git a/tests/golden/README.md b/tests/golden/README.md index 9fe14010..035c8ada 100644 --- a/tests/golden/README.md +++ b/tests/golden/README.md @@ -65,3 +65,8 @@ row's size from the build its id pulls (it had shown the MLX build's size while `pull ` fetched the GGUF), and started showing `mlx-ternary-bonsai-27b-2bit` for the one MLX-only row whose merge key names no registered model. Those sizes and that id are the only differences from the C++ capture. + +Four `uninstall` cases were edited when `wally uninstall` started keeping +downloaded models by default and gained `--delete-models` +(`help__uninstall`, `help__help__uninstall`, `leaf__uninstall__bogus_flag`, +`leaf__uninstall__extra_positional`). The options list is the only difference. diff --git a/tests/golden/expected/help__help__uninstall.stdout b/tests/golden/expected/help__help__uninstall.stdout index 6c1af275..9fce1578 100644 --- a/tests/golden/expected/help__help__uninstall.stdout +++ b/tests/golden/expected/help__help__uninstall.stdout @@ -5,3 +5,4 @@ Usage: wally uninstall [OPTIONS] Options: -h, --help Show help -y, --yes Skip the confirmation prompt + --delete-models Also delete downloaded models (kept by default) diff --git a/tests/golden/expected/help__uninstall.stdout b/tests/golden/expected/help__uninstall.stdout index 6c1af275..9fce1578 100644 --- a/tests/golden/expected/help__uninstall.stdout +++ b/tests/golden/expected/help__uninstall.stdout @@ -5,3 +5,4 @@ Usage: wally uninstall [OPTIONS] Options: -h, --help Show help -y, --yes Skip the confirmation prompt + --delete-models Also delete downloaded models (kept by default) diff --git a/tests/golden/expected/leaf__uninstall__bogus_flag.stderr b/tests/golden/expected/leaf__uninstall__bogus_flag.stderr index 95eb407f..0ea95863 100644 --- a/tests/golden/expected/leaf__uninstall__bogus_flag.stderr +++ b/tests/golden/expected/leaf__uninstall__bogus_flag.stderr @@ -6,3 +6,4 @@ Usage: wally uninstall [OPTIONS] Options: -h, --help Show help -y, --yes Skip the confirmation prompt + --delete-models Also delete downloaded models (kept by default) diff --git a/tests/golden/expected/leaf__uninstall__extra_positional.stderr b/tests/golden/expected/leaf__uninstall__extra_positional.stderr index d120262a..e2a5f304 100644 --- a/tests/golden/expected/leaf__uninstall__extra_positional.stderr +++ b/tests/golden/expected/leaf__uninstall__extra_positional.stderr @@ -6,3 +6,4 @@ Usage: wally uninstall [OPTIONS] Options: -h, --help Show help -y, --yes Skip the confirmation prompt + --delete-models Also delete downloaded models (kept by default)