diff --git a/base-convert/crates/base-convert/src/hub.rs b/base-convert/crates/base-convert/src/hub.rs index c14f829..d268b9c 100644 --- a/base-convert/crates/base-convert/src/hub.rs +++ b/base-convert/crates/base-convert/src/hub.rs @@ -1,4 +1,4 @@ -//! `basert pull` / `basert list` — the model-hub CLI surface — plus +//! `basert pull` / `basert list` / `basert rm` — the model-hub CLI surface — plus //! `dispatch_external`, the launcher that forwards `basert ` (serve, chat, //! …) to the matching `basert-` runtime binary. //! @@ -6,7 +6,7 @@ //! glue that drives it from the CLI and, for convert-on-pull, hands the //! downloaded snapshot to the existing `cmd_convert` pipeline. -use crate::{AwqMode, ConvertArgs, ListArgs, PullArgs, TargetScheme}; +use crate::{AwqMode, ConvertArgs, ListArgs, PullArgs, RmArgs, TargetScheme}; use anyhow::{bail, Context, Result}; use base_hub::cache::{self, HubSidecar}; use base_hub::fetch::{self, Fetcher, HfFetcher}; @@ -818,6 +818,12 @@ fn write_sidecar_for( ) } +pub fn cmd_rm(args: RmArgs) -> Result<()> { + cache::remove_model(&cache::models_dir()?, &args.model)?; + eprintln!("Removed {}", args.model.trim()); + Ok(()) +} + pub fn cmd_list(args: ListArgs) -> Result<()> { let reg = MergedRegistry::load()?; let rows = reg.list(args.remote)?; diff --git a/base-convert/crates/base-convert/src/main.rs b/base-convert/crates/base-convert/src/main.rs index 03dbd30..46c7542 100644 --- a/base-convert/crates/base-convert/src/main.rs +++ b/base-convert/crates/base-convert/src/main.rs @@ -21,6 +21,7 @@ Run models: Manage models: pull Download a model from the BaseRT catalog or Hugging Face list List installed models (`--remote` adds the catalog) + rm Remove all installed variants of a model Convert & author: convert Convert a GGUF / safetensors model to `.base` @@ -65,6 +66,8 @@ enum Cmd { Pull(PullArgs), /// List models in the local hub cache (and, with `--remote`, the catalog). List(ListArgs), + /// Remove all installed variants of a model, retaining HF source staging. + Rm(RmArgs), /// Regenerate the model catalog by scanning a published HF organization. CatalogScan(CatalogScanArgs), /// Runtime commands — `serve`, `chat`, `complete`, `bench`, … — handled @@ -291,6 +294,13 @@ struct ListArgs { json: bool, } +#[derive(Parser, Debug)] +struct RmArgs { + /// Installed model id (org/model), as shown by `basert list`. + #[arg(value_name = "MODEL")] + model: String, +} + /// BaseRT wordmark banner (mirrors the C++ CLIs — tools/basert_banner.h). /// White→lime vertical gradient anchored on brand Lime #E8FFBD (the brand /// tone alone is too close to white to read as terminal text). Printed only @@ -354,6 +364,7 @@ fn main() -> Result<()> { Cmd::Keygen(a) => cmd_keygen(a), Cmd::Pull(a) => hub::cmd_pull(a), Cmd::List(a) => hub::cmd_list(a), + Cmd::Rm(a) => hub::cmd_rm(a), Cmd::CatalogScan(a) => hub::cmd_catalog_scan(a.org, a.out, a.dry_run), Cmd::External(argv) => hub::dispatch_external(argv), } diff --git a/base-convert/crates/base-convert/tests/rm_e2e.rs b/base-convert/crates/base-convert/tests/rm_e2e.rs new file mode 100644 index 0000000..70273fe --- /dev/null +++ b/base-convert/crates/base-convert/tests/rm_e2e.rs @@ -0,0 +1,276 @@ +//! Model removal through the real CLI, using only isolated caches and no network. + +use base_hub::cache::{self, HubSidecar}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +fn run(root: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_basert")) + .args(args) + .env("BASERT_MODELS_DIR", root) + .env("BASERT_CATALOG_OFFLINE", "1") + .output() + .expect("run basert") +} + +fn install(root: &Path, id: &str, variant: &str) -> PathBuf { + let dir = cache::variant_dir(root, id, variant).unwrap(); + cache::write_sidecar( + &dir, + &HubSidecar { + id: id.into(), + source_kind: "huggingface".into(), + hf_repo: id.into(), + source_repo: None, + revision: "main".into(), + variant: variant.into(), + profile: None, + pulled_at: "2026-06-24T00:00:00Z".into(), + base_sha256: None, + }, + ) + .unwrap(); + // LocalRegistry recognizes even an unreadable header as installed. + std::fs::write(cache::base_artifact_path(&dir), b"model payload").unwrap(); + dir +} + +fn assert_error(out: &Output, message: &str) { + assert_eq!(out.status.code(), Some(1), "{out:?}"); + assert!( + String::from_utf8_lossy(&out.stderr).contains(message), + "{out:?}" + ); + assert!(!String::from_utf8_lossy(&out.stderr).contains("Removed")); +} + +#[test] +fn rm_removes_all_variants_in_custom_cache_and_preserves_other_models_and_sources() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("custom-models"); + let id = "Qwen/Qwen3-4B"; + install(&root, id, "default-q4"); + install(&root, id, "default-q8"); + let others = [ + install(&root, "Qwen/Other", "default-q4"), + install(&root, "meta-llama/Llama-3.2-1B", "default-q4"), + ]; + let before: Vec<_> = others + .iter() + .map(|dir| std::fs::read(dir.join(cache::SIDECAR_NAME)).unwrap()) + .collect(); + let staging = cache::hf_staging_dir(&root).join("models--Qwen--Qwen3-4B/blobs"); + std::fs::create_dir_all(&staging).unwrap(); + std::fs::write(staging.join("source"), b"retained source").unwrap(); + + let out = run(&root, &["rm", id]); + assert!(out.status.success(), "{out:?}"); + assert_eq!( + String::from_utf8(out.stderr).unwrap(), + "Removed Qwen/Qwen3-4B\n" + ); + assert!(!root.join(id).exists()); + assert!(root.is_dir()); + for (dir, sidecar) in others.iter().zip(before) { + assert_eq!( + std::fs::read(cache::base_artifact_path(dir)).unwrap(), + b"model payload" + ); + assert_eq!( + std::fs::read(dir.join(cache::SIDECAR_NAME)).unwrap(), + sidecar + ); + } + assert_eq!( + std::fs::read(staging.join("source")).unwrap(), + b"retained source" + ); + + let listed = run(&root, &["list", "--json"]); + assert!(listed.status.success(), "{listed:?}"); + let rows: serde_json::Value = serde_json::from_slice(&listed.stdout).unwrap(); + let ids: Vec<_> = rows + .as_array() + .unwrap() + .iter() + .map(|row| row["id"].as_str().unwrap()) + .collect(); + assert_eq!(ids, ["Qwen/Other", "meta-llama/Llama-3.2-1B"]); +} + +#[test] +fn rm_missing_model_and_uninstalled_directories_leave_data_untouched() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("models"); + assert_error(&run(&root, &["rm", "Qwen/DoesNotExist"]), "not installed"); + assert!(!root.exists(), "removal must not create the cache"); + let dir = install(&root, "Qwen/Qwen3-4B", "default-q4"); + let note = root.join("Qwen/DoesNotExist/notes.txt"); + std::fs::create_dir_all(note.parent().unwrap()).unwrap(); + std::fs::write(¬e, b"user data").unwrap(); + for id in ["Qwen/DoesNotExist", "Qwen", "Qwen/Qwen3-4B/default-q4"] { + assert_error(&run(&root, &["rm", id]), "not installed"); + } + assert_eq!(std::fs::read(note).unwrap(), b"user data"); + assert_eq!( + std::fs::read(cache::base_artifact_path(&dir)).unwrap(), + b"model payload" + ); + assert!(cache::read_sidecar(&dir).unwrap().is_some()); +} + +#[test] +fn rm_preserves_unrelated_files_and_nested_models() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let dir = install(root, "Qwen/Qwen3-4B", "default-q4"); + let nested = install(root, "Qwen/Qwen3-4B/nested", "default-q8"); + std::fs::write(dir.join("notes.txt"), b"user notes").unwrap(); + let out = run(root, &["rm", "Qwen/Qwen3-4B"]); + assert!(out.status.success(), "{out:?}"); + assert!(!cache::base_artifact_path(&dir).exists()); + assert!(!dir.join(cache::SIDECAR_NAME).exists()); + assert_eq!(std::fs::read(dir.join("notes.txt")).unwrap(), b"user notes"); + assert_eq!( + std::fs::read(cache::base_artifact_path(&nested)).unwrap(), + b"model payload" + ); + assert!(cache::read_sidecar(&nested).unwrap().is_some()); + let out = run(root, &["rm", "Qwen/Qwen3-4B/nested"]); + assert!(out.status.success(), "{out:?}"); + assert!(!nested.parent().unwrap().exists()); +} + +#[test] +fn rm_rejects_unsafe_ids_without_deleting_anything() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("models"); + let dir = install(&root, "Qwen/Qwen3-4B", "default-q4"); + let outside = install(tmp.path(), "outside/model", "default-q4"); + let staging = install(&root, ".src/hf", "snapshot"); + for id in [ + "", + " ", + "../", + "../../something", + "/", + "/absolute/path", + "../outside/model", + "Qwen/../../outside/model", + "Qwen/..", + "Qwen/./Qwen3-4B", + "Qwen//Qwen3-4B", + "Qwen/Qwen3-4B/", + "\\Qwen\\Qwen3-4B", + "C:/models", + "Qwen/Qwen3-4B:default-q4", + ".src", + ".src/hf", + "Qwen/\nQwen3-4B", + outside.parent().unwrap().to_str().unwrap(), + ] { + let out = run(&root, &["rm", id]); + assert_error(&out, "model id"); + } + for dir in [dir, outside, staging] { + assert_eq!( + std::fs::read(cache::base_artifact_path(&dir)).unwrap(), + b"model payload" + ); + assert!(cache::read_sidecar(&dir).unwrap().is_some()); + } +} + +#[cfg(unix)] +#[test] +fn rm_never_follows_model_variant_or_artifact_symlinks() { + use std::os::unix::fs::symlink; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("models"); + let outside = install(tmp.path(), "outside/model", "default-q4"); + let inside = install(&root, "Qwen/Other", "default-q4"); + symlink(tmp.path().join("outside"), root.join("External")).unwrap(); + symlink(inside.parent().unwrap(), root.join("Qwen/Alias")).unwrap(); + symlink(outside.parent().unwrap(), root.join("Qwen/External")).unwrap(); + for id in ["External/model", "Qwen/Alias", "Qwen/External"] { + assert_error(&run(&root, &["rm", id]), "without symlinks"); + } + let dir = root.join("Qwen/Linked"); + std::fs::create_dir_all(dir.join("artifact-link")).unwrap(); + symlink(&outside, dir.join("variant-link")).unwrap(); + symlink( + cache::base_artifact_path(&outside), + dir.join("artifact-link/model.base"), + ) + .unwrap(); + assert_error(&run(&root, &["rm", "Qwen/Linked"]), "not installed"); + for dir in [inside, outside] { + assert_eq!( + std::fs::read(cache::base_artifact_path(&dir)).unwrap(), + b"model payload" + ); + assert!(cache::read_sidecar(&dir).unwrap().is_some()); + } + assert!(dir.join("variant-link").is_symlink()); + assert!(dir.join("artifact-link/model.base").is_symlink()); +} + +#[test] +fn rm_handles_missing_sidecars_and_checks_sidecar_types_before_deletion() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let q4 = install(root, "Qwen/Qwen3-4B", "default-q4"); + let q8 = install(root, "Qwen/Qwen3-4B", "default-q8"); + std::fs::remove_file(q4.join(cache::SIDECAR_NAME)).unwrap(); + std::fs::remove_file(q8.join(cache::SIDECAR_NAME)).unwrap(); + // A directory at hub.json is not model metadata; preserve it and both + // artifacts even if the other variant was discovered first. + std::fs::create_dir(q8.join(cache::SIDECAR_NAME)).unwrap(); + assert_error( + &run(root, &["rm", "Qwen/Qwen3-4B"]), + "expected a sidecar file", + ); + for dir in [&q4, &q8] { + assert_eq!( + std::fs::read(cache::base_artifact_path(dir)).unwrap(), + b"model payload" + ); + } + std::fs::remove_dir(q8.join(cache::SIDECAR_NAME)).unwrap(); + let out = run(root, &["rm", "Qwen/Qwen3-4B"]); + assert!(out.status.success(), "{out:?}"); + assert!(!root.join("Qwen/Qwen3-4B").exists()); +} + +#[cfg(unix)] +#[test] +fn rm_unlinks_sidecar_symlink_without_deleting_its_target() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("models"); + let dir = install(&root, "Qwen/Qwen3-4B", "default-q4"); + let outside = tmp.path().join("user-data"); + std::fs::write(&outside, b"user data").unwrap(); + std::fs::remove_file(dir.join(cache::SIDECAR_NAME)).unwrap(); + std::os::unix::fs::symlink(&outside, dir.join(cache::SIDECAR_NAME)).unwrap(); + let out = run(&root, &["rm", "Qwen/Qwen3-4B"]); + assert!(out.status.success(), "{out:?}"); + assert!(!root.join("Qwen/Qwen3-4B").exists()); + assert_eq!(std::fs::read(outside).unwrap(), b"user data"); +} + +#[test] +fn rm_help_and_required_argument() { + let tmp = tempfile::tempdir().unwrap(); + for args in [&["--help"][..], &["rm", "--help"][..]] { + let out = run(tmp.path(), args); + assert!(out.status.success(), "{out:?}"); + assert!(String::from_utf8_lossy(&out.stdout).contains("rm")); + if args.len() == 2 { + assert!(String::from_utf8_lossy(&out.stdout).contains("rm ")); + } + } + let out = run(tmp.path(), &["rm"]); + assert_eq!(out.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&out.stderr).contains("")); +} diff --git a/base-convert/crates/base-hub/src/cache.rs b/base-convert/crates/base-hub/src/cache.rs index 43e2544..ef21e55 100644 --- a/base-convert/crates/base-hub/src/cache.rs +++ b/base-convert/crates/base-hub/src/cache.rs @@ -82,6 +82,98 @@ pub fn base_artifact_path(variant_dir: &Path) -> PathBuf { variant_dir.join(ARTIFACT_NAME) } +/// Remove every installed variant's artifact and provenance for this exact id. +/// Preserve staging, other files, and nested model ids; prune only empty dirs. +pub fn remove_model(root: &Path, id: &str) -> Result<()> { + let rel = id_to_relpath(id)?; + // The general path helper also accepts filesystem-style separators. A + // destructive command accepts only hub ids, never drive paths or variants. + if id.contains([':', '\\']) + || id.chars().any(char::is_control) + || rel.components().any(|c| c.as_os_str() == SRC_STAGING) + { + bail!("invalid model id for removal: {id:?}"); + } + let root = match std::fs::canonicalize(root) { + Ok(root) => root, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + bail!("model {id:?} is not installed"); + } + Err(e) => return Err(e).context("resolving model cache"), + }; + let mut model_dir = root.clone(); + for component in rel.components() { + model_dir.push(component); + let meta = match std::fs::symlink_metadata(&model_dir) { + Ok(meta) => meta, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + bail!("model {id:?} is not installed"); + } + Err(e) => return Err(e).with_context(|| format!("reading {}", model_dir.display())), + }; + // Refuse aliases even when they point to another model inside the cache. + if !meta.is_dir() || meta.is_symlink() { + bail!( + "model path must be a directory without symlinks: {}", + model_dir.display() + ); + } + } + let resolved = std::fs::canonicalize(&model_dir)?; + if resolved != model_dir || !resolved.starts_with(&root) || resolved == root { + bail!("unsafe model path: {}", model_dir.display()); + } + + // Like LocalRegistry, recognize regular model.base files without requiring + // a valid header or sidecar. Inspect all candidates before deleting anything. + let mut variants = Vec::new(); + for entry in std::fs::read_dir(&model_dir)? { + let entry = entry?; + if !entry.file_type()?.is_dir() || entry.file_name() == SRC_STAGING { + continue; + } + let dir = entry.path(); + let artifact = base_artifact_path(&dir); + match std::fs::symlink_metadata(&artifact) { + Ok(meta) if meta.is_file() => {} + Ok(_) => continue, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => return Err(e).with_context(|| format!("reading {}", artifact.display())), + } + let sidecar = dir.join(SIDECAR_NAME); + let has_sidecar = match std::fs::symlink_metadata(&sidecar) { + Ok(meta) if meta.is_file() || meta.is_symlink() => true, + Ok(_) => bail!("expected a sidecar file: {}", sidecar.display()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, + Err(e) => return Err(e).with_context(|| format!("reading {}", sidecar.display())), + }; + variants.push((dir, has_sidecar)); + } + if variants.is_empty() { + bail!("model {id:?} is not installed"); + } + variants.sort(); + for (dir, has_sidecar) in variants { + let artifact = base_artifact_path(&dir); + std::fs::remove_file(&artifact) + .with_context(|| format!("removing {}", artifact.display()))?; + if has_sidecar { + let sidecar = dir.join(SIDECAR_NAME); + std::fs::remove_file(&sidecar) + .with_context(|| format!("removing {}", sidecar.display()))?; + } + remove_empty_dir(&dir)?; + } + remove_empty_dir(&model_dir) +} + +fn remove_empty_dir(dir: &Path) -> Result<()> { + if std::fs::read_dir(dir)?.next().is_none() { + std::fs::remove_dir(dir).with_context(|| format!("removing {}", dir.display()))?; + } + Ok(()) +} + /// Root for hf-hub downloads: `/.src/hf`. Downloads land here (in /// hf-hub's own `models----/{blobs,snapshots,refs}` layout) instead /// of the user's global HuggingFace cache, so a pulled artifact is never diff --git a/docs/cli/overview.md b/docs/cli/overview.md index 643b49f..227e909 100644 --- a/docs/cli/overview.md +++ b/docs/cli/overview.md @@ -2,7 +2,7 @@ `basert` is a single front-end with two kinds of commands: -- **Native** (run by the CLI itself): `pull`, `list`, `convert`, `inspect`, +- **Native** (run by the CLI itself): `pull`, `list`, `rm`, `convert`, `inspect`, `sign`, `verify`, `keygen`. These are the model hub + converter. - **Forwarded** (dispatched to the engine): `serve`, `chat`, `complete`, `bench`, `profile`, `transcribe`. These exec the matching `basert-` diff --git a/docs/cli/reference.md b/docs/cli/reference.md index 2c68dab..cbb2041 100644 --- a/docs/cli/reference.md +++ b/docs/cli/reference.md @@ -36,6 +36,21 @@ basert list [--remote] [--json] | `--remote` | Also list catalog models that aren't installed yet. | | `--json` | Emit JSON instead of a table. | +## `basert rm` + +Remove all locally installed variants of an exact model id shown by `basert list`. + +```sh +basert rm +basert rm Qwen/Qwen3-4B +``` + +Deletes each installed variant's `model.base` and `hub.json`, then removes empty +variant/model directories. Other files and `.src` HF staging are retained. +Uses `$BASERT_MODELS_DIR`, like `pull` and `list`. No confirmation is required; +a missing model or unsafe id returns a non-zero exit status. Paths and +`:variant` selectors are not accepted. + ## `basert convert` Convert a source model (GGUF / HF / MLX) to `.base`. diff --git a/docs/guides/models.md b/docs/guides/models.md index 4544ece..0030bec 100644 --- a/docs/guides/models.md +++ b/docs/guides/models.md @@ -69,6 +69,21 @@ basert list --remote # also show catalog models not yet installed basert list --json # machine-readable ``` +## Removing + +```sh +basert rm Qwen/Qwen3-4B +``` + +Use the exact installed id from `basert list`. This removes `model.base` and +`hub.json` for all installed variants, then prunes empty variant/model +directories. Other files and nested models are preserved. Removal runs without +a confirmation prompt and returns an error if the model is not installed. + +Source snapshots and partial downloads under `.src` are retained, including +sources explicitly kept with `BASERT_KEEP_HF_SOURCES=1`. They are separate from +the installed variants and may be reused by later pulls. + ## Cache layout Models live under `$BASERT_MODELS_DIR` (default `~/.cache/baseRT/models`): @@ -77,7 +92,7 @@ Models live under `$BASERT_MODELS_DIR` (default `~/.cache/baseRT/models`): ~/.cache/baseRT/models/ ///model.base ← the artifact the runtime loads ///hub.json ← provenance sidecar - .src//// ← raw HF snapshot staging (ignored by list) + .src/hf/models----/ ← HF download staging (ignored by list) ``` `` encodes the quant profile (e.g. `default-q4`). The same directory is