Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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