From 1f470fe7d0b814a529b6d5f68839a16f2d1c1ef8 Mon Sep 17 00:00:00 2001 From: "Taylor H. Perkins" Date: Sun, 7 Jun 2026 20:36:53 -0700 Subject: [PATCH 1/4] Add `lms remove` command to delete downloaded models `lms` can download models (`lms get`) but has no way to delete them; the only guidance has been to manually `rm` files from the models folder (see #199, #223). This adds a `remove` (alias `rm`) subcommand. - `lms remove [modelKey]`: removes a downloaded model from disk. - No argument -> interactive fuzzy picker (mirrors `lms unload`). - With a model key -> targets that model directly. - Models with multiple downloaded variants prompt to remove a single variant or the entire model. - Shows the model in the same table format as `lms ls`, plus its on-disk location, then asks for confirmation. `-y/--yes` skips it. - Refuses to remove a model that is currently loaded (asks the user to `lms unload` first). - Prunes now-empty publisher/repo folders after deletion. Refactor: extract `resolveModelsFolderPath`/`locateSettingsJson` out of `importCmd.ts` into a shared `modelsFolder.ts` and reuse it (adds an `ensureExists` option so removal doesn't recreate the folder). Export `printDownloadedModelsTable` from `list.ts` so the output matches `lms ls`. Co-Authored-By: Claude Opus 4.8 --- src/index.ts | 3 +- src/modelsFolder.ts | 68 +++++++ src/subcommands/importCmd.ts | 61 +------ src/subcommands/list.ts | 4 +- src/subcommands/remove.test.ts | 35 ++++ src/subcommands/remove.ts | 312 +++++++++++++++++++++++++++++++++ 6 files changed, 421 insertions(+), 62 deletions(-) create mode 100644 src/modelsFolder.ts create mode 100644 src/subcommands/remove.test.ts create mode 100644 src/subcommands/remove.ts diff --git a/src/index.ts b/src/index.ts index 74fa715d..b4fe78ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,7 @@ import { log } from "./subcommands/log.js"; import { login } from "./subcommands/login.js"; import { logout } from "./subcommands/logout.js"; import { push } from "./subcommands/push.js"; +import { remove } from "./subcommands/remove.js"; import { runtime } from "./subcommands/runtime/index.js"; import { server } from "./subcommands/server.js"; import { status } from "./subcommands/status.js"; @@ -165,7 +166,7 @@ Learn more: ${chalk.blue("https://lmstudio.ai/docs/developer")} Join our Discord: ${chalk.blue("https://discord.gg/lmstudio")}`, ); -addCommandsGroup("Local models", [chat, get, load, unload, ls, ps, importCmd], "#22D3EE"); +addCommandsGroup("Local models", [chat, get, load, unload, remove, ls, ps, importCmd], "#22D3EE"); addCommandsGroup("Serve", [server, log], "#34D399"); addCommandsGroup("Remote Instances", [link], "#818CF8"); addCommandsGroup("Runtime", [runtime], "#C084FC"); diff --git a/src/modelsFolder.ts b/src/modelsFolder.ts new file mode 100644 index 00000000..b7a4a4e4 --- /dev/null +++ b/src/modelsFolder.ts @@ -0,0 +1,68 @@ +import { type SimpleLogger } from "@lmstudio/lms-common"; +import { findLMStudioHome } from "@lmstudio/lms-common-server"; +import { access, mkdir, readFile } from "fs/promises"; +import { join } from "path"; +import { defaultModelsFolder } from "./lmstudioPaths.js"; + +/** + * Locate the settings.json file of LM Studio. + * + * @param logger - The logger to use. + * @returns A promise that resolves with the path to the settings.json file, or null if it does not + * exist. + */ +export async function locateSettingsJson(logger: SimpleLogger) { + logger.debug("Locating settings.json"); + const lmstudioHome = findLMStudioHome(); + const settingsJsonFilePath = join(lmstudioHome, "settings.json"); + logger.debug("Settings.json file path", settingsJsonFilePath); + try { + await access(settingsJsonFilePath); + return settingsJsonFilePath; + } catch (error) { + logger.debug("settings.json does not exist", error); + return null; + } +} + +/** + * Resolve the path to the models folder. If the settings.json file exists, use the downloadsFolder + * field. Otherwise, fall back to the default models folder. + * + * @param logger - The logger to use. + * @param opts - Options. Set `ensureExists` to `false` to skip creating the folder if it does not + * exist (useful for read-only operations such as listing or removing models). + * @returns A promise that resolves with the path to the models folder. + */ +export async function resolveModelsFolderPath( + logger: SimpleLogger, + { ensureExists = true }: { ensureExists?: boolean } = {}, +) { + const settingsJsonPath = await locateSettingsJson(logger); + let modelsFolderPath = defaultModelsFolder; + if (settingsJsonPath === null) { + logger.warn( + "Could not locate LM Studio configuration file, using default path:", + modelsFolderPath, + ); + } else { + try { + const content = await readFile(settingsJsonPath, "utf8"); + const settings = JSON.parse(content); + modelsFolderPath = settings.downloadsFolder; + if (typeof modelsFolderPath !== "string") { + throw new Error("downloadsFolder is not a string"); + } + } catch (error) { + logger.warn( + "Could not parse LM Studio configuration file, using default path:", + modelsFolderPath, + ); + logger.debug(error); + } + } + if (ensureExists) { + await mkdir(modelsFolderPath, { recursive: true }); + } + return modelsFolderPath; +} diff --git a/src/subcommands/importCmd.ts b/src/subcommands/importCmd.ts index 2ca07e70..fe92b0e4 100644 --- a/src/subcommands/importCmd.ts +++ b/src/subcommands/importCmd.ts @@ -7,18 +7,17 @@ import { type SimpleLogger, text, } from "@lmstudio/lms-common"; -import { findLMStudioHome } from "@lmstudio/lms-common-server"; import { terminalSize } from "@lmstudio/lms-isomorphic"; import chalk from "chalk"; import { existsSync, statSync } from "fs"; -import { access, copyFile, link, mkdir, readFile, rename, symlink } from "fs/promises"; +import { access, copyFile, link, mkdir, rename, symlink } from "fs/promises"; import fuzzy from "fuzzy"; import { homedir } from "os"; import { basename, dirname, join } from "path"; import { z } from "zod"; import { getCliPref } from "../cliPref.js"; -import { defaultModelsFolder } from "../lmstudioPaths.js"; import { addLogLevelOptions, createLogger, type LogLevelArgs } from "../logLevel.js"; +import { resolveModelsFolderPath } from "../modelsFolder.js"; import { runPromptWithExitHandling } from "../prompt.js"; import { fuzzyHighlightOptions, searchTheme } from "../inquirerTheme.js"; @@ -340,62 +339,6 @@ function getUserAppDataPath() { } } -/** - * Locate the settings.json file of LM Studio. - * - * @param logger - The logger to use. - * @returns A promise that resolves with the path to the settings.json file, or null if it does not - * exist. - */ -async function locateSettingsJson(logger: SimpleLogger) { - logger.debug("Locating settings.json"); - const lmstudioHome = findLMStudioHome(); - const settingsJsonFilePath = join(lmstudioHome, "settings.json"); - logger.debug("Settings.json file path", settingsJsonFilePath); - try { - await access(settingsJsonFilePath); - return settingsJsonFilePath; - } catch (error) { - logger.debug("settings.json does not exist", error); - return null; - } -} - -/** - * Resolve the path to the models folder. If the settings.json file exists, use the downloadsFolder - * field. - * - * @param logger - The logger to use. - * @returns A promise that resolves with the path to the models folder. - */ -async function resolveModelsFolderPath(logger: SimpleLogger) { - const settingsJsonPath = await locateSettingsJson(logger); - let modelsFolderPath = defaultModelsFolder; - if (settingsJsonPath === null) { - logger.warn( - "Could not locate LM Studio configuration file, using default path:", - modelsFolderPath, - ); - } else { - try { - const content = await readFile(settingsJsonPath, "utf8"); - const settings = JSON.parse(content); - modelsFolderPath = settings.downloadsFolder; - if (typeof modelsFolderPath !== "string") { - throw new Error("downloadsFolder is not a string"); - } - } catch (error) { - logger.warn( - "Could not parse LM Studio configuration file, using default path:", - modelsFolderPath, - ); - logger.debug(error); - } - } - await mkdir(modelsFolderPath, { recursive: true }); - return modelsFolderPath; -} - /** * Warn the user about moving the file to the models folder if they have not been warned before. * diff --git a/src/subcommands/list.ts b/src/subcommands/list.ts index 86a2be07..7f590d9f 100644 --- a/src/subcommands/list.ts +++ b/src/subcommands/list.ts @@ -40,7 +40,7 @@ function formatModelKeyWithVariantCount(model: ModelInfo) { return `${model.modelKey}${chalk.dim(` (${variantCount} ${variantLabel})`)}`; } -type LoadedModelInfo = { +export type LoadedModelInfo = { modelKey: string; identifier: string; deviceIdentifier: string | null; @@ -74,7 +74,7 @@ function countLoadedOnDevice( ).length; } -function printDownloadedModelsTable( +export function printDownloadedModelsTable( title: string, downloadedModels: Array, loadedModels: Array, diff --git a/src/subcommands/remove.test.ts b/src/subcommands/remove.test.ts new file mode 100644 index 00000000..48f1478a --- /dev/null +++ b/src/subcommands/remove.test.ts @@ -0,0 +1,35 @@ +import { join } from "path"; +import { pathIsAtOrInside } from "./remove.js"; + +describe("remove", () => { + describe("pathIsAtOrInside", () => { + const modelsFolder = join("/home", "user", ".lmstudio", "models"); + + it("returns true when the child is the same as the parent", () => { + expect(pathIsAtOrInside(modelsFolder, modelsFolder)).toBe(true); + }); + + it("returns true for a directly nested path", () => { + expect(pathIsAtOrInside(modelsFolder, join(modelsFolder, "publisher"))).toBe(true); + }); + + it("returns true for a deeply nested path", () => { + expect( + pathIsAtOrInside(modelsFolder, join(modelsFolder, "publisher", "repo", "model.gguf")), + ).toBe(true); + }); + + it("returns false for a sibling whose name shares a prefix", () => { + // "/.../models-backup" must NOT be considered inside "/.../models". + expect(pathIsAtOrInside(modelsFolder, `${modelsFolder}-backup`)).toBe(false); + }); + + it("returns false for a parent of the parent", () => { + expect(pathIsAtOrInside(modelsFolder, join("/home", "user", ".lmstudio"))).toBe(false); + }); + + it("returns false for a completely unrelated path", () => { + expect(pathIsAtOrInside(modelsFolder, join("/tmp", "something"))).toBe(false); + }); + }); +}); diff --git a/src/subcommands/remove.ts b/src/subcommands/remove.ts new file mode 100644 index 00000000..fc03db14 --- /dev/null +++ b/src/subcommands/remove.ts @@ -0,0 +1,312 @@ +import { Command, type OptionValues } from "@commander-js/extra-typings"; +import { search, select } from "@inquirer/prompts"; +import { makeTitledPrettyError, text } from "@lmstudio/lms-common"; +import { terminalSize } from "@lmstudio/lms-isomorphic"; +import { type EmbeddingModel, type LLM, type LMStudioClient, type ModelInfo } from "@lmstudio/sdk"; +import chalk from "chalk"; +import { readdir, rm, rmdir } from "fs/promises"; +import fuzzy from "fuzzy"; +import { dirname, isAbsolute, join, relative } from "path"; +import { askQuestion } from "../confirm.js"; +import { addCreateClientOptions, createClient, type CreateClientArgs } from "../createClient.js"; +import { createDeviceNameResolver } from "../deviceNameLookup.js"; +import { formatSizeBytes1000 } from "../formatBytes.js"; +import { fuzzyHighlightOptions, searchTheme } from "../inquirerTheme.js"; +import { addLogLevelOptions, createLogger, type LogLevelArgs } from "../logLevel.js"; +import { resolveModelsFolderPath } from "../modelsFolder.js"; +import { runPromptWithExitHandling } from "../prompt.js"; +import { printDownloadedModelsTable } from "./list.js"; + +type RemoveCommandOptions = OptionValues & + CreateClientArgs & + LogLevelArgs & { + yes?: boolean; + }; + +const removeCommand = new Command<[], RemoveCommandOptions>() + .name("remove") + .alias("rm") + .description("Remove a downloaded model from disk") + .argument( + "[modelKey]", + text` + The model key of the model to remove. If not provided, you will be prompted to select a model + interactively from a list. Run ${chalk.yellow("lms ls")} to see your downloaded models. + `, + ) + .option( + "-y, --yes", + text` + Skip the confirmation prompt. Deletion is permanent, so use this with caution. + `, + ); + +addCreateClientOptions(removeCommand); +addLogLevelOptions(removeCommand); + +removeCommand.action(async (modelKey, options: RemoveCommandOptions) => { + const logger = createLogger(options); + const { yes = false } = options; + await using client = await createClient(logger, options); + const deviceNameResolver = await createDeviceNameResolver(client, logger); + + // Only models stored on this machine can be removed by deleting files. Remote models (available + // via LM Link on another device) are filtered out. + const downloadedModels = (await client.system.listDownloadedModels()).filter(model => + deviceNameResolver.isLocal(model.deviceIdentifier), + ); + + if (downloadedModels.length === 0) { + logger.errorWithoutPrefix( + makeTitledPrettyError( + "No Models Found", + text` + You don't have any local models to remove. To download one, run: + + ${chalk.yellow("lms get ")} + `, + ).message, + ); + process.exit(1); + } + + // Resolve which downloaded model the user wants to remove. + let baseModel: ModelInfo; + if (modelKey !== undefined) { + const matchingModels = downloadedModels.filter(model => model.modelKey === modelKey); + if (matchingModels.length === 0) { + logger.errorWithoutPrefix( + makeTitledPrettyError( + "Model Not Found", + text` + Cannot find a downloaded model with the key "${chalk.yellow(modelKey)}". + + To see a list of downloaded models, run: + + ${chalk.yellow("lms ls")} + `, + ).message, + ); + process.exit(1); + } + baseModel = matchingModels[0]; + } else { + baseModel = await promptForModel(downloadedModels); + } + + // A model key can have multiple downloaded variants (e.g. different quantizations) that live in + // the same folder. Let the user choose a single variant or the entire model. + const target = await resolveRemovalTarget(client, baseModel); + + const modelsFolderPath = await resolveModelsFolderPath(logger, { ensureExists: false }); + const absolutePath = join(modelsFolderPath, target.path); + + // Refuse to delete a model whose files are currently loaded in memory. + const blockingLoaded = await findLoadedModelsAtPath(client, modelsFolderPath, absolutePath); + if (blockingLoaded.length > 0) { + const identifiers = blockingLoaded + .map(identifier => ` ${chalk.yellow(identifier)}`) + .join("\n"); + logger.errorWithoutPrefix( + makeTitledPrettyError( + "Model In Use", + text` + This model is currently loaded and cannot be removed: + + ${identifiers} + + Unload it first, then try again: + + ${chalk.yellow("lms unload")} + `, + ).message, + ); + process.exit(1); + } + + // Show what will be removed using the same table format as `lms ls`. + const title = target.type === "embedding" ? "EMBEDDING" : "LLM"; + console.info(); + console.info(chalk.yellow("The following model will be permanently removed:")); + console.info(); + printDownloadedModelsTable(title, [target], [], deviceNameResolver); + console.info(); + console.info(`${chalk.dim("Location:")} ${absolutePath}`); + console.info(); + + if (!yes) { + const confirmed = await askQuestion( + chalk.redBright( + `Permanently delete this model (${formatSizeBytes1000(target.sizeBytes)})? This cannot be undone.`, + ), + ); + if (!confirmed) { + logger.info("Aborted. No models were removed."); + return; + } + } + + await rm(absolutePath, { recursive: true, force: true }); + await pruneEmptyParents(absolutePath, modelsFolderPath); + + logger.info( + `Removed "${target.modelKey}", freeing ${formatSizeBytes1000(target.sizeBytes)} of disk space.`, + ); +}); + +/** + * Prompt the user to interactively select a downloaded model from a fuzzy-searchable list. + * + * @param models - The downloaded models to choose from. + * @returns A promise that resolves with the selected model. + */ +async function promptForModel(models: Array): Promise { + // Used to split the model key from a dimmed, non-searchable description suffix. + const searchDelimiter = ""; + const searchStrings = models.map( + model => + `${model.modelKey}${searchDelimiter} ${formatSizeBytes1000(model.sizeBytes)} · ${model.type}`, + ); + const pageSize = terminalSize().rows - 5; + return await runPromptWithExitHandling(() => + search( + { + message: chalk.green("Select a model to remove") + chalk.dim(" |"), + pageSize, + theme: searchTheme, + source: async (input: string | undefined, { signal }: { signal: AbortSignal }) => { + void signal; + const options = fuzzy.filter(input ?? "", searchStrings, fuzzyHighlightOptions); + return options.map(option => { + const model = models[option.index]; + if (model === undefined) { + throw new Error("Search results returned an invalid model index."); + } + const [keyPart, suffixPart] = option.string.split(searchDelimiter); + return { + value: model, + short: model.modelKey, + name: suffixPart === undefined ? keyPart : `${keyPart}${chalk.dim(suffixPart)}`, + }; + }); + }, + }, + { output: process.stderr }, + ), + ); +} + +/** + * Determine the exact target to remove. If the model has more than one downloaded variant, prompt + * the user to either remove a single variant or the entire model. + * + * @param client - The LM Studio client. + * @param baseModel - The model selected by the user. + * @returns A promise that resolves with the ModelInfo to remove (either a single variant or the + * base model representing all variants). + */ +async function resolveRemovalTarget( + client: LMStudioClient, + baseModel: ModelInfo, +): Promise { + if (baseModel.variants === undefined || baseModel.variants.length <= 1) { + return baseModel; + } + const variants = await client.system.listDownloadedModelVariants(baseModel.modelKey); + if (variants.length <= 1) { + return baseModel; + } + // `null` represents "remove the entire model" (i.e. the base model / whole folder). + const selected = await runPromptWithExitHandling(() => + select( + { + message: chalk.green( + `"${baseModel.modelKey}" has multiple variants. What do you want to remove?`, + ), + choices: [ + { + name: text` + All variants + ${chalk.dim(`(entire model, ${formatSizeBytes1000(baseModel.sizeBytes)})`)} + `, + value: null, + }, + ...variants.map(variant => ({ + name: `${variant.modelKey} ${chalk.dim(`(${formatSizeBytes1000(variant.sizeBytes)})`)}`, + value: variant, + })), + ], + }, + { output: process.stderr }, + ), + ); + return selected ?? baseModel; +} + +/** + * Find any currently-loaded models whose files live at (or inside) the given path. Used to prevent + * removing a model that is in use. + * + * @param client - The LM Studio client. + * @param modelsFolderPath - The absolute path to the models folder. + * @param absolutePath - The absolute path that is about to be removed. + * @returns A promise that resolves with the identifiers of the blocking loaded models. + */ +async function findLoadedModelsAtPath( + client: LMStudioClient, + modelsFolderPath: string, + absolutePath: string, +): Promise> { + const loadedModels: Array = ( + await Promise.all([client.llm.listLoaded(), client.embedding.listLoaded()]) + ).flat(); + return loadedModels + .filter(model => pathIsAtOrInside(absolutePath, join(modelsFolderPath, model.path))) + .map(model => model.identifier); +} + +/** + * Remove now-empty parent directories left behind after deleting a model, walking up towards (but + * never removing) the models folder itself. This avoids leaving empty publisher/repo folders. + * + * @param absolutePath - The absolute path that was removed. + * @param modelsFolderPath - The absolute path to the models folder (the boundary to stop at). + */ +async function pruneEmptyParents(absolutePath: string, modelsFolderPath: string): Promise { + let directory = dirname(absolutePath); + while (pathIsAtOrInside(modelsFolderPath, directory) && directory !== modelsFolderPath) { + let entries: Array; + try { + entries = await readdir(directory); + } catch { + break; + } + if (entries.length > 0) { + break; + } + try { + await rmdir(directory); + } catch { + break; + } + directory = dirname(directory); + } +} + +/** + * Determine whether `childPath` is equal to or nested inside `parentPath`. Comparison is done on + * path segments so that, for example, "/models/ab" is not considered inside "/models/a". + * + * @param parentPath - The candidate parent (outer) path. + * @param childPath - The candidate child (inner) path. + * @returns `true` if `childPath` is `parentPath` or is contained within it. + */ +export function pathIsAtOrInside(parentPath: string, childPath: string): boolean { + if (parentPath === childPath) { + return true; + } + const relativePath = relative(parentPath, childPath); + return relativePath.length > 0 && !relativePath.startsWith("..") && !isAbsolute(relativePath); +} + +export const remove = removeCommand; From e3d00183c67ae5df04859727ee2a661dac4fda6e Mon Sep 17 00:00:00 2001 From: "Taylor H. Perkins" Date: Sun, 7 Jun 2026 20:53:43 -0700 Subject: [PATCH 2/4] remove: ignore remote (LM Link) loaded models when blocking deletion The loaded-model guard compared only the relative path, so a model loaded on a remote LM Link device with the same path would block deleting the local download even though local files aren't in use. Filter loaded entries to the local device (via getModelInfo().deviceIdentifier + deviceNameResolver.isLocal) before the path-containment check, matching how `unload` distinguishes local vs remote models. Addresses Codex review feedback on #580. Co-Authored-By: Claude Opus 4.8 --- src/subcommands/remove.ts | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/src/subcommands/remove.ts b/src/subcommands/remove.ts index fc03db14..3174327f 100644 --- a/src/subcommands/remove.ts +++ b/src/subcommands/remove.ts @@ -9,7 +9,7 @@ import fuzzy from "fuzzy"; import { dirname, isAbsolute, join, relative } from "path"; import { askQuestion } from "../confirm.js"; import { addCreateClientOptions, createClient, type CreateClientArgs } from "../createClient.js"; -import { createDeviceNameResolver } from "../deviceNameLookup.js"; +import { createDeviceNameResolver, type DeviceNameResolver } from "../deviceNameLookup.js"; import { formatSizeBytes1000 } from "../formatBytes.js"; import { fuzzyHighlightOptions, searchTheme } from "../inquirerTheme.js"; import { addLogLevelOptions, createLogger, type LogLevelArgs } from "../logLevel.js"; @@ -102,7 +102,12 @@ removeCommand.action(async (modelKey, options: RemoveCommandOptions) => { const absolutePath = join(modelsFolderPath, target.path); // Refuse to delete a model whose files are currently loaded in memory. - const blockingLoaded = await findLoadedModelsAtPath(client, modelsFolderPath, absolutePath); + const blockingLoaded = await findLoadedModelsAtPath( + client, + deviceNameResolver, + modelsFolderPath, + absolutePath, + ); if (blockingLoaded.length > 0) { const identifiers = blockingLoaded .map(identifier => ` ${chalk.yellow(identifier)}`) @@ -245,24 +250,38 @@ async function resolveRemovalTarget( /** * Find any currently-loaded models whose files live at (or inside) the given path. Used to prevent - * removing a model that is in use. + * removing a model that is in use. Models loaded on remote devices (via LM Link) are ignored, since + * deleting local files does not affect them. * * @param client - The LM Studio client. + * @param deviceNameResolver - Resolver used to tell local models apart from remote ones. * @param modelsFolderPath - The absolute path to the models folder. * @param absolutePath - The absolute path that is about to be removed. * @returns A promise that resolves with the identifiers of the blocking loaded models. */ async function findLoadedModelsAtPath( client: LMStudioClient, + deviceNameResolver: DeviceNameResolver, modelsFolderPath: string, absolutePath: string, ): Promise> { const loadedModels: Array = ( await Promise.all([client.llm.listLoaded(), client.embedding.listLoaded()]) ).flat(); - return loadedModels - .filter(model => pathIsAtOrInside(absolutePath, join(modelsFolderPath, model.path))) - .map(model => model.identifier); + const blockingIdentifiers = await Promise.all( + loadedModels.map(async model => { + const modelInfo = await model.getModelInfo(); + // Only a model loaded on this machine can block deleting the local files. + if (!deviceNameResolver.isLocal(modelInfo.deviceIdentifier ?? null)) { + return null; + } + if (pathIsAtOrInside(absolutePath, join(modelsFolderPath, model.path))) { + return model.identifier; + } + return null; + }), + ); + return blockingIdentifiers.filter((identifier): identifier is string => identifier !== null); } /** From 1ef284a8821136b5e4227146bb1083048e7b126b Mon Sep 17 00:00:00 2001 From: "Taylor H. Perkins" Date: Sun, 7 Jun 2026 22:37:27 -0700 Subject: [PATCH 3/4] remove: refuse to run against a remote --host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `lms remove` deletes files from the locally-resolved models folder, but it inherits `--host` from addCreateClientOptions. With `--host ` it would list models from the remote instance while deleting local paths — either silently doing nothing or removing the wrong local model. Since there is no server-side delete RPC, fail fast when `--host` is set and tell the user to run the command on the machine where the model is stored. Addresses Codex review feedback (P1) on #580. Co-Authored-By: Claude Opus 4.8 --- src/subcommands/remove.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/subcommands/remove.ts b/src/subcommands/remove.ts index 3174327f..636b1eb1 100644 --- a/src/subcommands/remove.ts +++ b/src/subcommands/remove.ts @@ -47,6 +47,26 @@ addLogLevelOptions(removeCommand); removeCommand.action(async (modelKey, options: RemoveCommandOptions) => { const logger = createLogger(options); const { yes = false } = options; + + // `lms remove` deletes files from the local models folder, so it can only operate on the local + // LM Studio installation. If the user points the CLI at a remote instance via --host, the listed + // models live on that machine while deletion would target local paths — refuse instead. + if (options.host !== undefined) { + logger.errorWithoutPrefix( + makeTitledPrettyError( + "Remote Removal Not Supported", + text` + ${chalk.yellow("lms remove")} deletes model files from the local models folder and cannot + remove models from a remote LM Studio instance + (${chalk.yellow(`--host ${options.host}`)}). + + Run ${chalk.yellow("lms remove")} directly on the machine where the model is stored. + `, + ).message, + ); + process.exit(1); + } + await using client = await createClient(logger, options); const deviceNameResolver = await createDeviceNameResolver(client, logger); From b1a7f9ad67a2a91f16f47f73dd7f9c89e16bacd1 Mon Sep 17 00:00:00 2001 From: "Taylor H. Perkins" Date: Sun, 7 Jun 2026 23:38:46 -0700 Subject: [PATCH 4/4] remove: filter variants to local device before prompting resolveRemovalTarget looked up variants by model key only, which can include variants downloaded on LM Link peers. Since only base models were filtered to local devices, a remote-only variant could be offered and, when selected, either no-op while reporting success or delete a local file with the same relative path. Filter variants with deviceNameResolver.isLocal(variant.deviceIdentifier) before building the removal choices. Addresses Codex review feedback (P2) on #580. Co-Authored-By: Claude Opus 4.8 --- src/subcommands/remove.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/subcommands/remove.ts b/src/subcommands/remove.ts index 636b1eb1..c266557f 100644 --- a/src/subcommands/remove.ts +++ b/src/subcommands/remove.ts @@ -116,7 +116,7 @@ removeCommand.action(async (modelKey, options: RemoveCommandOptions) => { // A model key can have multiple downloaded variants (e.g. different quantizations) that live in // the same folder. Let the user choose a single variant or the entire model. - const target = await resolveRemovalTarget(client, baseModel); + const target = await resolveRemovalTarget(client, deviceNameResolver, baseModel); const modelsFolderPath = await resolveModelsFolderPath(logger, { ensureExists: false }); const absolutePath = join(modelsFolderPath, target.path); @@ -226,18 +226,24 @@ async function promptForModel(models: Array): Promise { * the user to either remove a single variant or the entire model. * * @param client - The LM Studio client. + * @param deviceNameResolver - Resolver used to keep only locally-stored variants. * @param baseModel - The model selected by the user. * @returns A promise that resolves with the ModelInfo to remove (either a single variant or the * base model representing all variants). */ async function resolveRemovalTarget( client: LMStudioClient, + deviceNameResolver: DeviceNameResolver, baseModel: ModelInfo, ): Promise { if (baseModel.variants === undefined || baseModel.variants.length <= 1) { return baseModel; } - const variants = await client.system.listDownloadedModelVariants(baseModel.modelKey); + // The variant lookup is by model key only, so it can include variants downloaded on LM Link + // peers. Only locally-stored variants can be removed by deleting local files. + const variants = (await client.system.listDownloadedModelVariants(baseModel.modelKey)).filter( + variant => deviceNameResolver.isLocal(variant.deviceIdentifier), + ); if (variants.length <= 1) { return baseModel; }