From 0e201fdba7bfb23234155b7195d2d8564d58c47b Mon Sep 17 00:00:00 2001 From: Rugved Somwanshi Date: Tue, 29 Jul 2025 13:42:20 -0400 Subject: [PATCH 1/3] Environment support for multiple hosts or ports - Redo --- src/EnvironmentManager.ts | 172 ++++++++++++++++++++++++++++++++++++++ src/createClient.ts | 68 +++++++++++---- src/index.ts | 2 + src/lmstudioPaths.ts | 1 + src/subcommands/env.ts | 157 ++++++++++++++++++++++++++++++++++ src/subcommands/status.ts | 7 +- 6 files changed, 392 insertions(+), 15 deletions(-) create mode 100644 src/EnvironmentManager.ts create mode 100644 src/subcommands/env.ts diff --git a/src/EnvironmentManager.ts b/src/EnvironmentManager.ts new file mode 100644 index 00000000..3ea52b23 --- /dev/null +++ b/src/EnvironmentManager.ts @@ -0,0 +1,172 @@ +import { readFile, writeFile, mkdir, access, unlink, readdir } from "fs/promises"; +import { join } from "path"; +import { lmsConfigFolder } from "./lmstudioPaths.js"; +import { z } from "zod"; +import { type SimpleLogger } from "@lmstudio/lms-common"; +import { type CreateClientArgs } from "./createClient.js"; +const environmentConfigSchema = z.object({ + name: z.string(), + host: z.string(), + port: z.number().int().min(0).max(65535), + description: z.string().optional(), +}); + +export type EnvironmentConfig = z.infer; + +export const DEFAULT_LOCAL_ENVIRONMENT_NAME = "local"; + +const DEFAULT_ENVIRONMENT_CONFIG: EnvironmentConfig = { + name: DEFAULT_LOCAL_ENVIRONMENT_NAME, + host: "localhost", + port: 1234, + description: "Default local environment", +}; + +export class EnvironmentManager { + private environmentsDir: string; + private currentEnvFile: string; + + public constructor( + private readonly logger: SimpleLogger, + private readonly createClientArgs: CreateClientArgs | undefined = undefined, + ) { + const configDir = lmsConfigFolder; + this.environmentsDir = join(configDir, "environments"); + this.currentEnvFile = join(configDir, "current-env"); + } + + private async ensureDirExists(): Promise { + await mkdir(this.environmentsDir, { recursive: true }); + } + + public async addEnvironment(config: EnvironmentConfig): Promise { + await this.ensureDirExists(); + const envPath = join(this.environmentsDir, `${config.name}.json`); + try { + await access(envPath); + throw new Error(`Environment ${config.name} already exists.`); + } catch { + await writeFile(envPath, JSON.stringify(config, null, 2), "utf-8"); + } + } + + public async removeEnvironment(name: string): Promise { + const envPath = join(this.environmentsDir, `${name}.json`); + try { + await unlink(envPath); + // Check if this was the current environment + try { + const currentEnv = await readFile(this.currentEnvFile, "utf-8"); + if (currentEnv === name) { + await writeFile(this.currentEnvFile, DEFAULT_LOCAL_ENVIRONMENT_NAME, "utf-8"); + } + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + await writeFile(this.currentEnvFile, DEFAULT_LOCAL_ENVIRONMENT_NAME, "utf-8"); + } else { + // Re-throw other types of errors + throw error; + } + } + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + throw new Error(`Environment ${name} does not exist.`); + } else { + throw new Error(`Failed to remove environment ${name}: ${(error as Error).message}`); + } + } + } + + public async setCurrentEnvironment(name: string): Promise { + if (name === "local") { + // Special case for local environment + await writeFile(this.currentEnvFile, DEFAULT_LOCAL_ENVIRONMENT_NAME, "utf-8"); + return; + } + const envPath = join(this.environmentsDir, `${name}.json`); + try { + const data = await readFile(envPath, "utf-8"); + environmentConfigSchema.parse(JSON.parse(data)); // Validate schema + await writeFile(this.currentEnvFile, name, "utf-8"); + } catch { + throw new Error(`Environment ${name} does not exist.`); + } + } + + public async getCurrentEnvironment(): Promise { + if (this.createClientArgs !== undefined) { + if (this.createClientArgs.host !== undefined || this.createClientArgs.port !== undefined) { + // Check if --host and --port are provided in createClientOpts + // If so, return a temporary environment config + const tempEnv: EnvironmentConfig = { + name: "temporary", + host: this.createClientArgs.host ?? "localhost", + port: this.createClientArgs.port ?? 1234, + }; + return tempEnv; + } + } + + let envName: string; + + // Check if LMS_ENV is set in the environment variables + // This takes precedence over the currentEnvFile + if ( + process.env.LMS_ENV !== undefined && + process.env.LMS_ENV !== "undefined" && + process.env.LMS_ENV !== "null" + ) { + envName = process.env.LMS_ENV; + } else { + try { + envName = (await readFile(this.currentEnvFile, "utf-8")).trim(); + } catch { + envName = DEFAULT_LOCAL_ENVIRONMENT_NAME; + } + } + if (envName === undefined || envName === "" || envName === DEFAULT_LOCAL_ENVIRONMENT_NAME) { + return DEFAULT_ENVIRONMENT_CONFIG; + } + + const env = await this.tryGetEnvironment(envName); + if (env === undefined) { + this.logger.warn(`Environment ${envName} not found, falling back to local.`); + await writeFile(this.currentEnvFile, DEFAULT_LOCAL_ENVIRONMENT_NAME, "utf-8"); + return DEFAULT_ENVIRONMENT_CONFIG; + } + + return env; + } + + public async getAllEnvironments(): Promise { + await this.ensureDirExists(); + const files = await readdir(this.environmentsDir); + const environments: EnvironmentConfig[] = [DEFAULT_ENVIRONMENT_CONFIG]; + for (const file of files) { + if (file.endsWith(".json")) { + try { + const data = await readFile(join(this.environmentsDir, file), "utf-8"); + const parsed = environmentConfigSchema.parse(JSON.parse(data)); + environments.push(parsed); + } catch (error) { + this.logger.warn(`Failed to load environment from ${file}: ${(error as Error).message}`); + } + } + } + return environments; + } + + public async tryGetEnvironment(name: string): Promise { + if (name === DEFAULT_LOCAL_ENVIRONMENT_NAME) { + return DEFAULT_ENVIRONMENT_CONFIG; // Return default local environment + } + await this.ensureDirExists(); + const envPath = join(this.environmentsDir, `${name}.json`); + try { + const data = await readFile(envPath, "utf-8"); + return environmentConfigSchema.parse(JSON.parse(data)); + } catch { + return undefined; // Environment does not exist + } + } +} diff --git a/src/createClient.ts b/src/createClient.ts index f72ad18e..ded3b4ba 100644 --- a/src/createClient.ts +++ b/src/createClient.ts @@ -1,7 +1,6 @@ import { Option, type Command, type OptionValues } from "@commander-js/extra-typings"; import { apiServerPorts, text, type SimpleLogger } from "@lmstudio/lms-common"; import { LMStudioClient, type LMStudioClientConstructorOpts } from "@lmstudio/sdk"; -import chalk from "chalk"; import { spawn } from "child_process"; import { randomBytes } from "crypto"; import { readFile } from "fs/promises"; @@ -9,6 +8,7 @@ import { exists } from "./exists.js"; import { appInstallLocationFilePath, lmsKey2Path } from "./lmstudioPaths.js"; import { type LogLevelArgs } from "./logLevel.js"; import { createRefinedNumberParser } from "./types/refinedNumber.js"; +import { DEFAULT_LOCAL_ENVIRONMENT_NAME, EnvironmentManager } from "./EnvironmentManager.js"; /** * Checks if the HTTP server is running. */ @@ -78,13 +78,22 @@ export function addCreateClientOptions< (default), the last used port will be used; otherwise, 1234 will be used. `, ).argParser(createRefinedNumberParser({ integer: true, min: 0, max: 65535 })), + ) + .addOption( + new Option( + "--env ", + text` + If you wish to connect to a remote LM Studio instance, specify the env here + `, + ), ); } -interface CreateClientArgs { +export interface CreateClientArgs { yes?: boolean; host?: string; port?: number; + env?: string; } async function isLocalServerAtPortLMStudioServerOrThrow(port: number) { @@ -151,12 +160,48 @@ export async function createClient( args: CreateClientArgs & LogLevelArgs, _opts: CreateClientOpts = {}, ) { - let { host, port } = args; - let isRemote = true; - if (host === undefined) { - isRemote = false; - host = "127.0.0.1"; - } else if (host.includes("://")) { + const envManager = new EnvironmentManager(logger, args); + let currentEnv = await envManager.getCurrentEnvironment(); + let host = currentEnv.host; + let port = currentEnv.port; + + // If the user has provided both host and port and env, we will throw an error. + if (args.host !== undefined && args.port !== undefined && args.env !== undefined) { + logger.error( + text` + You cannot specify both --host/--port and --env at the same time. Please choose one method + to specify the server to connect to. + `, + ); + process.exit(1); + } + if (args.env !== undefined) { + // If the user specified an environment, we will override the current environment with the specified one. + const specificedEnv = await envManager.tryGetEnvironment(args.env); + if (specificedEnv === undefined) { + logger.error(`Environment '${args.env}' not found`); + process.exit(1); + } + host = specificedEnv.host; + port = specificedEnv.port; + currentEnv = specificedEnv; + } + let isRemote = currentEnv.name !== DEFAULT_LOCAL_ENVIRONMENT_NAME; + + // If host and port are provided, they take precedence over the environment. + if (args.host !== undefined) { + host = args.host; + + // If host is anything but remote, set isRemote to false + if (host === "127.0.0.1" || host === "localhost" || host === "0.0.0.0") { + isRemote = false; + } + } + if (args.port !== undefined) { + port = args.port; + } + + if (host.includes("://")) { logger.error("Host should not include the protocol."); process.exit(1); } else if (host.includes(":")) { @@ -202,7 +247,7 @@ export async function createClient( } } } - if (port === undefined && host === "127.0.0.1") { + if (isRemote === false) { // We will now attempt to connect to the local API server. const localPort = await tryFindLocalAPIServer(); @@ -242,11 +287,6 @@ export async function createClient( logger.error(""); } - - if (port === undefined) { - port = 1234; - } - logger.debug(`Connecting to server at ${host}:${port}`); if (!(await checkHttpServer(logger, port, host))) { logger.error( diff --git a/src/index.ts b/src/index.ts index 57c0165f..5bf21d58 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import { server } from "./subcommands/server.js"; import { status } from "./subcommands/status.js"; import { unload } from "./subcommands/unload.js"; import { printVersion, version } from "./subcommands/version.js"; +import { env } from "./subcommands/env.js"; if (process.argv.length === 2) { printVersion(); @@ -46,6 +47,7 @@ program.addCommand(push); program.commandsGroup("System Management:"); program.addCommand(bootstrap); +progrma.addCommand(env); program.addCommand(flags); program.addCommand(log); program.addCommand(status); diff --git a/src/lmstudioPaths.ts b/src/lmstudioPaths.ts index 9caab638..ae152ed1 100644 --- a/src/lmstudioPaths.ts +++ b/src/lmstudioPaths.ts @@ -2,6 +2,7 @@ import { findLMStudioHome } from "@lmstudio/lms-common-server"; import { join } from "path"; const lmstudioHome = findLMStudioHome(); +export const lmsConfigFolder = join(lmstudioHome, "lms"); //TODO: Temporary path export const pluginsFolderPath = join(lmstudioHome, "extensions", "plugins"); export const lmsKey2Path = join(lmstudioHome, ".internal", "lms-key-2"); export const cliPrefPath = join(lmstudioHome, ".internal", "cli-pref.json"); diff --git a/src/subcommands/env.ts b/src/subcommands/env.ts new file mode 100644 index 00000000..869497e6 --- /dev/null +++ b/src/subcommands/env.ts @@ -0,0 +1,157 @@ +import { Command } from "@commander-js/extra-typings"; +import { text } from "@lmstudio/lms-common"; +import { EnvironmentManager } from "../EnvironmentManager.js"; +import { addLogLevelOptions, createLogger } from "../logLevel.js"; +import { createRefinedNumberParser } from "../types/refinedNumber.js"; + +const portParser = createRefinedNumberParser({ integer: true, min: 0, max: 65535 }); + +const addEnvCommand = addLogLevelOptions( + new Command() + .name("add") + .description("Add a new environment") + .argument("", "Environment name") + .requiredOption("--host ", "Host address") + .requiredOption("--port ", "Port number", portParser) + .option("--description ", "Environment description"), +).action(async (name, options) => { + const logger = createLogger(options); + const envManager = new EnvironmentManager(logger); + try { + await envManager.addEnvironment({ + name, + host: options.host, + port: options.port, + description: options.description, + }); + logger.info(`Environment '${name}' added successfully`); + } catch (error) { + logger.error(`Failed to add environment: ${(error as Error).message}`); + process.exit(1); + } +}); + +const removeEnvCommand = addLogLevelOptions( + new Command() + .name("remove") + .description("Remove an environment") + .argument("", "Environment name to remove"), +).action(async (name, options) => { + const logger = createLogger(options); + const envManager = new EnvironmentManager(logger); + try { + await envManager.removeEnvironment(name); + logger.info(`Environment '${name}' removed successfully`); + } catch (error) { + logger.error(`Failed to remove environment: ${(error as Error).message}`); + process.exit(1); + } +}); + +const listEnvCommand = addLogLevelOptions( + new Command().name("ls").description("List all environments"), +).action(async options => { + const logger = createLogger(options); + const envManager = new EnvironmentManager(logger); + try { + const environments = await envManager.getAllEnvironments(); + const current = await envManager.getCurrentEnvironment(); + + if (environments.length === 0) { + logger.info("No environments found"); + return; + } + + logger.info("Available environments:"); + for (const env of environments) { + const isCurrent = env.name === current.name; + const marker = isCurrent ? "* " : " "; + const desc = env.description !== undefined ? ` - ${env.description}` : ""; + logger.info(`${marker}${env.name} (${env.host}:${env.port})${desc}`); + } + + // Show default local environment if not in list + const hasLocal = environments.some(env => env.name === "local"); + if (!hasLocal) { + const marker = current.name === "local" ? "* " : " "; + logger.info(`${marker}local (localhost:1234) - Default local environment`); + } + } catch (error) { + logger.error(`Failed to list environments: ${(error as Error).message}`); + process.exit(1); + } +}); + +const useEnvCommand = addLogLevelOptions( + new Command() + .name("use") + .description("Switch to an environment") + .argument("", "Environment name to switch to"), +).action(async (name, options) => { + const logger = createLogger(options); + const envManager = new EnvironmentManager(logger); + try { + await envManager.setCurrentEnvironment(name); + logger.info(`Switched to environment '${name}'`); + } catch (error) { + logger.error(`Failed to switch environment: ${(error as Error).message}`); + process.exit(1); + } +}); + +const currentEnvCommand = addLogLevelOptions( + new Command().name("current").description("Show current environment"), +).action(async options => { + const logger = createLogger(options); + const envManager = new EnvironmentManager(logger); + try { + const current = await envManager.getCurrentEnvironment(); + const desc = current.description !== undefined ? ` - ${current.description}` : ""; + logger.info(`Current environment: ${current.name} (${current.host}:${current.port})${desc}`); + } catch (error) { + logger.error(`Failed to get current environment: ${(error as Error).message}`); + process.exit(1); + } +}); + +const inspectEnvCommand = addLogLevelOptions( + new Command() + .name("inspect") + .description("Show detailed information about an environment") + .argument("", "Environment name to inspect"), +).action(async (name, options) => { + const logger = createLogger(options); + const envManager = new EnvironmentManager(logger); + try { + const env = await envManager.tryGetEnvironment(name); + if (!env) { + logger.error(`Environment '${name}' not found`); + process.exit(1); + } + + logger.info(`Environment: ${env.name}`); + logger.info(`Host: ${env.host}`); + logger.info(`Port: ${env.port}`); + if (env.description !== undefined) { + logger.info(`Description: ${env.description}`); + } + } catch (error) { + logger.error(`Failed to inspect environment: ${(error as Error).message}`); + process.exit(1); + } +}); + +export const env = new Command() + .name("env") + .description( + text` + Manage LM Studio environments. Environments allow you to switch between different + LM Studio instances (local or remote) easily. + `, + ) + .addCommand(addEnvCommand) + .addCommand(removeEnvCommand) + .addCommand(listEnvCommand) + .addCommand(useEnvCommand) + .addCommand(currentEnvCommand) + .addCommand(inspectEnvCommand); diff --git a/src/subcommands/status.ts b/src/subcommands/status.ts index 997ce3bb..cdd94bec 100644 --- a/src/subcommands/status.ts +++ b/src/subcommands/status.ts @@ -5,6 +5,7 @@ import { addCreateClientOptions, checkHttpServer, createClient } from "../create import { formatSizeBytes1000 } from "../formatSizeBytes1000.js"; import { addLogLevelOptions, createLogger } from "../logLevel.js"; import { getServerConfig } from "./server.js"; +import { EnvironmentManager } from "../EnvironmentManager.js"; export const status = addLogLevelOptions( addCreateClientOptions( @@ -12,7 +13,11 @@ export const status = addLogLevelOptions( ), ).action(async options => { const logger = createLogger(options); - let { host, port } = options; + const envManager = new EnvironmentManager(logger, options); + const currentEnv = await envManager.getCurrentEnvironment(); + let host = currentEnv.host; + let port = currentEnv.port; + if (host === undefined) { host = "127.0.0.1"; } From e56d771d4543c4e77939590632882eb71800e077 Mon Sep 17 00:00:00 2001 From: Rugved Somwanshi Date: Thu, 4 Sep 2025 17:28:08 -0400 Subject: [PATCH 2/3] Add chalk import --- src/createClient.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/createClient.ts b/src/createClient.ts index ded3b4ba..72ac9d4f 100644 --- a/src/createClient.ts +++ b/src/createClient.ts @@ -9,6 +9,7 @@ import { appInstallLocationFilePath, lmsKey2Path } from "./lmstudioPaths.js"; import { type LogLevelArgs } from "./logLevel.js"; import { createRefinedNumberParser } from "./types/refinedNumber.js"; import { DEFAULT_LOCAL_ENVIRONMENT_NAME, EnvironmentManager } from "./EnvironmentManager.js"; +import chalk from "chalk"; /** * Checks if the HTTP server is running. */ From cbcefbe3a6e80eeb7b8e726d970b453554248778 Mon Sep 17 00:00:00 2001 From: Rugved Somwanshi Date: Tue, 23 Sep 2025 10:57:56 -0400 Subject: [PATCH 3/3] Add comments in env manager --- src/EnvironmentManager.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/EnvironmentManager.ts b/src/EnvironmentManager.ts index 3ea52b23..71a61a5e 100644 --- a/src/EnvironmentManager.ts +++ b/src/EnvironmentManager.ts @@ -4,13 +4,13 @@ import { lmsConfigFolder } from "./lmstudioPaths.js"; import { z } from "zod"; import { type SimpleLogger } from "@lmstudio/lms-common"; import { type CreateClientArgs } from "./createClient.js"; + const environmentConfigSchema = z.object({ name: z.string(), host: z.string(), port: z.number().int().min(0).max(65535), description: z.string().optional(), }); - export type EnvironmentConfig = z.infer; export const DEFAULT_LOCAL_ENVIRONMENT_NAME = "local"; @@ -18,6 +18,8 @@ export const DEFAULT_LOCAL_ENVIRONMENT_NAME = "local"; const DEFAULT_ENVIRONMENT_CONFIG: EnvironmentConfig = { name: DEFAULT_LOCAL_ENVIRONMENT_NAME, host: "localhost", + // TODO: This will have to change based on which port the current user is running their local + // server on and consider multiple users on the same machine using different ports port: 1234, description: "Default local environment", }; @@ -39,6 +41,9 @@ export class EnvironmentManager { await mkdir(this.environmentsDir, { recursive: true }); } + /** + * Adds a new environment configuration. + */ public async addEnvironment(config: EnvironmentConfig): Promise { await this.ensureDirExists(); const envPath = join(this.environmentsDir, `${config.name}.json`); @@ -50,6 +55,9 @@ export class EnvironmentManager { } } + /** + * Removes an existing environment configuration. + */ public async removeEnvironment(name: string): Promise { const envPath = join(this.environmentsDir, `${name}.json`); try { @@ -77,6 +85,9 @@ export class EnvironmentManager { } } + /** + * Sets the current environment by name. + */ public async setCurrentEnvironment(name: string): Promise { if (name === "local") { // Special case for local environment @@ -93,6 +104,9 @@ export class EnvironmentManager { } } + /** + * Gets the current environment configuration. + */ public async getCurrentEnvironment(): Promise { if (this.createClientArgs !== undefined) { if (this.createClientArgs.host !== undefined || this.createClientArgs.port !== undefined) { @@ -138,6 +152,9 @@ export class EnvironmentManager { return env; } + /** + * Lists all available environment configurations. + */ public async getAllEnvironments(): Promise { await this.ensureDirExists(); const files = await readdir(this.environmentsDir); @@ -156,6 +173,9 @@ export class EnvironmentManager { return environments; } + /** + * Tries to get an environment configuration by name. Returns undefined if not found. + */ public async tryGetEnvironment(name: string): Promise { if (name === DEFAULT_LOCAL_ENVIRONMENT_NAME) { return DEFAULT_ENVIRONMENT_CONFIG; // Return default local environment