Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand Down
68 changes: 68 additions & 0 deletions src/modelsFolder.ts
Original file line number Diff line number Diff line change
@@ -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;
}
61 changes: 2 additions & 59 deletions src/subcommands/importCmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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.
*
Expand Down
4 changes: 2 additions & 2 deletions src/subcommands/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -74,7 +74,7 @@ function countLoadedOnDevice(
).length;
}

function printDownloadedModelsTable(
export function printDownloadedModelsTable(
title: string,
downloadedModels: Array<ModelInfo>,
loadedModels: Array<LoadedModelInfo>,
Expand Down
35 changes: 35 additions & 0 deletions src/subcommands/remove.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading
Loading