mirror of
https://github.com/lmstudio-ai/lms.git
synced 2025-08-02 19:14:38 +08:00
@ -18,6 +18,7 @@
|
||||
"@lmstudio/lms-isomorphic": "^0.3.2",
|
||||
"@lmstudio/lms-lmstudio": "^0.0.13",
|
||||
"@lmstudio/sdk": "^0.2.0",
|
||||
"@lmstudio/lms-es-plugin-runner": "^0.0.1",
|
||||
"boxen": "^5.1.2",
|
||||
"chalk": "^4.1.2",
|
||||
"cmd-ts": "^0.13.0",
|
||||
|
@ -10,7 +10,7 @@ export async function askQuestion(prompt: string): Promise<boolean> {
|
||||
cleaner.register(() => rl.close());
|
||||
let answer: boolean | undefined;
|
||||
do {
|
||||
const answerText = await rl.question(prompt);
|
||||
const answerText = await rl.question(prompt + " (Y/N): ");
|
||||
if (answerText.toUpperCase() === "Y") {
|
||||
answer = true;
|
||||
} else if (answerText.toUpperCase() === "N") {
|
||||
|
10
src/exists.ts
Normal file
10
src/exists.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { access } from "fs/promises";
|
||||
|
||||
export async function exists(path: string) {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
47
src/findProjectFolder.ts
Normal file
47
src/findProjectFolder.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { type SimpleLogger } from "@lmstudio/lms-common";
|
||||
import { access } from "fs/promises";
|
||||
import { dirname, join, resolve } from "path/posix";
|
||||
|
||||
/**
|
||||
* From the given folder, recursively travels back up, until finds one folder with manifest.json.
|
||||
*/
|
||||
export async function findProjectFolder(logger: SimpleLogger, cwd: string) {
|
||||
let currentDir = resolve(cwd);
|
||||
|
||||
let maximumDepth = 20;
|
||||
while (maximumDepth > 0) {
|
||||
maximumDepth--;
|
||||
const manifestPath = join(currentDir, "manifest.json");
|
||||
logger.debug("Trying to access", manifestPath);
|
||||
try {
|
||||
await access(manifestPath);
|
||||
logger.debug("Found manifest.json at", currentDir);
|
||||
return currentDir;
|
||||
} catch (err) {
|
||||
const parentDir = dirname(currentDir);
|
||||
if (parentDir === currentDir) {
|
||||
// Reached the root directory without finding manifest.json
|
||||
return null;
|
||||
}
|
||||
currentDir = parentDir;
|
||||
}
|
||||
}
|
||||
logger.debug("Reached maximum depth without finding manifest.json");
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function findProjectFolderOrExit(logger: SimpleLogger, cwd: string) {
|
||||
const projectFolder = await findProjectFolder(logger, cwd);
|
||||
if (projectFolder === null) {
|
||||
logger.errorText`
|
||||
Could not find the project folder. Please invoke this command in a folder with a
|
||||
manifest.json file.
|
||||
`;
|
||||
logger.errorText`
|
||||
To create an empty plugin, use the \`lms create\` command, or create a new plugin in
|
||||
LM Studio.
|
||||
`;
|
||||
process.exit(1);
|
||||
}
|
||||
return projectFolder;
|
||||
}
|
@ -29,6 +29,8 @@ const cli = subcommands({
|
||||
unload,
|
||||
create,
|
||||
log,
|
||||
// dev,
|
||||
// push,
|
||||
import: importCmd,
|
||||
version,
|
||||
bootstrap,
|
||||
|
4
src/lmstudioPaths.ts
Normal file
4
src/lmstudioPaths.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
export const pluginsFolderPath = join(homedir(), ".cache", "lm-studio", "extensions", "plugins");
|
230
src/subcommands/dev.ts
Normal file
230
src/subcommands/dev.ts
Normal file
@ -0,0 +1,230 @@
|
||||
import { SimpleLogger, text, Validator } from "@lmstudio/lms-common";
|
||||
import { EsPluginRunnerWatcher, UtilBinary } from "@lmstudio/lms-es-plugin-runner";
|
||||
import { pluginManifestSchema } from "@lmstudio/lms-shared-types/dist/PluginManifest";
|
||||
import {
|
||||
type LMStudioClient,
|
||||
type PluginManifest,
|
||||
type RegisterDevelopmentPluginOpts,
|
||||
} from "@lmstudio/sdk";
|
||||
import { type ChildProcessWithoutNullStreams } from "child_process";
|
||||
import { boolean, command, flag } from "cmd-ts";
|
||||
import { cp, mkdir, readFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { cwd } from "process";
|
||||
import { askQuestion } from "../confirm";
|
||||
import { createClient, createClientArgs } from "../createClient";
|
||||
import { exists } from "../exists";
|
||||
import { findProjectFolderOrExit } from "../findProjectFolder";
|
||||
import { pluginsFolderPath } from "../lmstudioPaths";
|
||||
import { createLogger, logLevelArgs } from "../logLevel";
|
||||
|
||||
type PluginProcessStatus = "stopped" | "starting" | "running" | "restarting";
|
||||
|
||||
class PluginProcess {
|
||||
public constructor(
|
||||
private readonly client: LMStudioClient,
|
||||
private readonly registerDevelopmentPluginOpts: RegisterDevelopmentPluginOpts,
|
||||
private readonly cwd: string,
|
||||
private readonly logger: SimpleLogger,
|
||||
) {}
|
||||
private readonly node = new UtilBinary("node");
|
||||
private readonly args = ["--enable-source-maps", join(".lmstudio", "dev.js")];
|
||||
private readonly serverLogger = new SimpleLogger("plugin-server", this.logger);
|
||||
private readonly stderrLogger = new SimpleLogger("stderr", this.logger);
|
||||
|
||||
private currentProcess: ChildProcessWithoutNullStreams | null = null;
|
||||
private status: PluginProcessStatus = "stopped";
|
||||
private unregister: (() => Promise<void>) | null = null;
|
||||
private firstTime = true;
|
||||
|
||||
private async startProcess() {
|
||||
this.status = "starting";
|
||||
const { unregister, clientIdentifier, clientPasskey } =
|
||||
await this.client.plugins.registerDevelopmentPlugin(this.registerDevelopmentPluginOpts);
|
||||
if (this.firstTime) {
|
||||
const manifest = this.registerDevelopmentPluginOpts.manifest;
|
||||
const identifier = `${manifest.owner}/${manifest.name}`;
|
||||
await this.client.system.notify({
|
||||
title: `Plugin "${identifier}" started`,
|
||||
description: "This plugin is run by lms CLI development server.",
|
||||
});
|
||||
this.firstTime = false;
|
||||
}
|
||||
this.unregister = unregister;
|
||||
this.currentProcess = this.node.spawn(this.args, {
|
||||
env: {
|
||||
FORCE_COLOR: "1",
|
||||
...process.env,
|
||||
LMS_PLUGIN_CLIENT_IDENTIFIER: clientIdentifier,
|
||||
LMS_PLUGIN_CLIENT_PASSKEY: clientPasskey,
|
||||
},
|
||||
cwd: this.cwd,
|
||||
});
|
||||
this.currentProcess.stdout.on("data", data => this.logger.info(data.toString("utf-8").trim()));
|
||||
this.currentProcess.stderr.on("data", data =>
|
||||
this.stderrLogger.error(data.toString("utf-8").trim()),
|
||||
);
|
||||
this.currentProcess.on("exit", async (code, signal) => {
|
||||
await this.unregister?.();
|
||||
this.unregister = null;
|
||||
if (code !== null) {
|
||||
this.serverLogger.warn(`Plugin process exited with code ${code}`);
|
||||
} else {
|
||||
if (signal === "SIGKILL") {
|
||||
// OK to ignore because we killed it
|
||||
} else {
|
||||
this.serverLogger.warn(`Plugin process exited with signal ${signal}`);
|
||||
}
|
||||
}
|
||||
if (this.status === "restarting") {
|
||||
this.startProcess();
|
||||
} else {
|
||||
this.status = "stopped";
|
||||
}
|
||||
});
|
||||
this.status = "running";
|
||||
}
|
||||
public run() {
|
||||
switch (this.status) {
|
||||
case "stopped": {
|
||||
this.startProcess();
|
||||
break;
|
||||
}
|
||||
case "starting": {
|
||||
// Already starting. Do nothing.
|
||||
break;
|
||||
}
|
||||
case "running": {
|
||||
this.status = "restarting";
|
||||
if (this.unregister === null) {
|
||||
this.currentProcess?.kill("SIGKILL");
|
||||
this.currentProcess = null;
|
||||
} else {
|
||||
this.unregister().then(() => {
|
||||
this.unregister = null;
|
||||
this.currentProcess?.kill("SIGKILL");
|
||||
this.currentProcess = null;
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "restarting": {
|
||||
// Already restarting. Do nothing.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const dev = command({
|
||||
name: "dev",
|
||||
description: "Starts the development server for the plugin in the current folder.",
|
||||
args: {
|
||||
install: flag({
|
||||
type: boolean,
|
||||
long: "install",
|
||||
short: "i",
|
||||
description: text`
|
||||
When specified, instead of starting the development server, installs the plugin to
|
||||
LM Studio.
|
||||
`,
|
||||
}),
|
||||
yes: flag({
|
||||
type: boolean,
|
||||
long: "yes",
|
||||
short: "y",
|
||||
description: text`
|
||||
Suppress all confirmations and warnings. Useful for scripting.
|
||||
|
||||
- When used with --install, it will overwrite the plugin without asking.
|
||||
`,
|
||||
}),
|
||||
...logLevelArgs,
|
||||
...createClientArgs,
|
||||
},
|
||||
handler: async args => {
|
||||
const logger = createLogger(args);
|
||||
const client = await createClient(logger, args);
|
||||
const projectPath = await findProjectFolderOrExit(logger, cwd());
|
||||
const { install, yes } = args;
|
||||
const manifestPath = join(projectPath, "manifest.json");
|
||||
const manifestParseResult = pluginManifestSchema.safeParse(
|
||||
JSON.parse(await readFile(manifestPath, "utf-8")),
|
||||
);
|
||||
if (!manifestParseResult.success) {
|
||||
logger.error("Failed to parse the manifest file.");
|
||||
logger.error(Validator.prettyPrintZod("manifest", manifestParseResult.error));
|
||||
process.exit(1);
|
||||
}
|
||||
const manifest = manifestParseResult.data;
|
||||
|
||||
if (install) {
|
||||
process.exit(await handleInstall(projectPath, manifest, logger, client, { yes }));
|
||||
} else {
|
||||
await handleDevServer(projectPath, manifest, logger, client);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function handleInstall(
|
||||
projectPath: string,
|
||||
manifest: PluginManifest,
|
||||
logger: SimpleLogger,
|
||||
client: LMStudioClient,
|
||||
{ yes }: { yes: boolean },
|
||||
): Promise<number> {
|
||||
// Currently, we naively copy paste the entire plugin folder to LM Studio, and then trigger a
|
||||
// plugin re-index.
|
||||
logger.info(`Installing the plugin ${manifest.owner}/${manifest.name}...`);
|
||||
logger.debug("Copying from", projectPath);
|
||||
const destinationPath = join(pluginsFolderPath, manifest.owner, manifest.name);
|
||||
logger.debug("To", pluginsFolderPath);
|
||||
|
||||
if ((await exists(destinationPath)) && !yes) {
|
||||
const result = await askQuestion(text`
|
||||
Plugin ${manifest.owner}/${manifest.name} already exists. Do you want to overwrite it?
|
||||
`);
|
||||
if (!result) {
|
||||
logger.info("Installation cancelled.");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
await mkdir(destinationPath, { recursive: true });
|
||||
const startTime = Date.now();
|
||||
await cp(projectPath, destinationPath, {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
});
|
||||
const endTime = Date.now();
|
||||
logger.debug(`Copied in ${endTime - startTime}ms.`);
|
||||
|
||||
logger.debug("Reindexing plugins...");
|
||||
await client.plugins.reindexPlugins();
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function handleDevServer(
|
||||
projectPath: string,
|
||||
manifest: PluginManifest,
|
||||
logger: SimpleLogger,
|
||||
client: LMStudioClient,
|
||||
) {
|
||||
logger.info(`Starting the development server for ${manifest.owner}/${manifest.name}...`);
|
||||
|
||||
const esbuild = new UtilBinary("esbuild");
|
||||
const watcher = new EsPluginRunnerWatcher(esbuild, cwd(), logger);
|
||||
|
||||
const pluginProcess = new PluginProcess(client, { manifest }, projectPath, logger);
|
||||
|
||||
watcher.updatedEvent.subscribe(() => {
|
||||
pluginProcess.run();
|
||||
});
|
||||
await watcher.start();
|
||||
|
||||
client.system.whenDisconnected().then(() => {
|
||||
logger.info("Disconnected from the server. Stopping the development server.");
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
@ -273,7 +273,7 @@ export const get = command({
|
||||
isAskingExitingBehavior = true;
|
||||
logger.infoWithoutPrefix();
|
||||
process.stdin.resume();
|
||||
askQuestion("Continue to download in the background? (Y/N): ").then(confirmed => {
|
||||
askQuestion("Continue to download in the background?").then(confirmed => {
|
||||
if (confirmed) {
|
||||
logger.info("Download will continue in the background.");
|
||||
process.exit(1);
|
||||
|
20
src/subcommands/push.ts
Normal file
20
src/subcommands/push.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { command } from "cmd-ts";
|
||||
import { cwd } from "process";
|
||||
import { createClient, createClientArgs } from "../createClient";
|
||||
import { findProjectFolderOrExit } from "../findProjectFolder";
|
||||
import { createLogger, logLevelArgs } from "../logLevel";
|
||||
|
||||
export const push = command({
|
||||
name: "push",
|
||||
description: "Uploads the plugin in the current folder to LM Studio Hub.",
|
||||
args: {
|
||||
...logLevelArgs,
|
||||
...createClientArgs,
|
||||
},
|
||||
handler: async args => {
|
||||
const logger = createLogger(args);
|
||||
const client = await createClient(logger, args);
|
||||
const projectPath = await findProjectFolderOrExit(logger, cwd());
|
||||
await client.repository.push(projectPath);
|
||||
},
|
||||
});
|
@ -24,6 +24,8 @@ export function printVersion() {
|
||||
console.info(`\x1b[38;5;231mlms - LM Studio CLI - v${getVersion()}\x1b[0m`);
|
||||
// console.info("Licensed under the MIT License");
|
||||
console.info(chalk.gray("GitHub: https://github.com/lmstudio-ai/lmstudio-cli"));
|
||||
|
||||
console.info(process.execPath);
|
||||
}
|
||||
|
||||
export const version = command({
|
||||
|
Reference in New Issue
Block a user