<project title="Lms" summary="The purpose of this project is to provide a robust, user-friendly command-line interface for managing LM Studio’s local LLM models and runtime environment. It enables users to automate tasks such as model loading, server management, project creation, and logging. The CLI is designed to be extensible, supporting subcommands for various workflows, and integrates with the underlying lmstudio.js library for seamless interaction with the LM Studio application.">**Remember:**
- CLI (Command-Line Interface)
- LM Studio
- Model Management (load/unload/list)
- Runtime Server Control
- Subcommands
- Monorepo Integration<concepts><doc title="Architecturestylizations" desc="core concept.">import chalk from "chalk";

export class InfoLookup<TInnerKey, TLookupKey, TValue> {
  private readonly lookup = new Map<TInnerKey, TValue>();
  private readonly fallback: (key: TLookupKey) => TValue;

  private constructor(
    private readonly keyMapper: (key: TLookupKey) => TInnerKey,
    fallback: ((key: TLookupKey) => TValue) | undefined,
  ) {
    this.fallback =
      fallback ??
      (key => {
        throw new Error(`Key not found: ${key}`);
      });
  }

  public static create<TKey, TValue>({ fallback }: { fallback?: (key: TKey) => TValue } = {}) {
    return new InfoLookup<TKey, TKey, TValue>(key => key, fallback);
  }

  public static createWithKeyMapper<TInnerKey, TLookupKey, TValue>({
    fallback,
    keyMapper,
  }: {
    fallback: (key: TLookupKey) => TValue;
    keyMapper: (key: TLookupKey) => TInnerKey;
  }) {
    return new InfoLookup<TInnerKey, TLookupKey, TValue>(keyMapper, fallback);
  }

  public register(...args: [...TInnerKey[], TValue]): this {
    const value = args.at(-1) as TValue;
    for (let i = 0; i < args.length - 1; i++) {
      this.lookup.set(args[i] as TInnerKey, value);
    }
    return this;
  }

  public find(lookupKey: TLookupKey): TValue {
    const innerKey = this.keyMapper(lookupKey);
    if (this.lookup.has(innerKey)) {
      return this.lookup.get(innerKey)!;
    } else {
      return this.fallback(lookupKey);
    }
  }
}

const llmColorer = chalk.cyan;
const visionColorer = chalk.yellow;
const embeddingColorer = chalk.blue;

export const architectureInfoLookup = InfoLookup.createWithKeyMapper({
  fallback: (arch: string) => ({
    name: arch,
    colorer: llmColorer,
  }),
  keyMapper: (arch: string) => arch.toLowerCase(),
})
  .register("phi2", "phi-2", {
    name: "Phi-2",
    colorer: llmColorer,
  })
  .register("phi3", "phi-3", {
    name: "Phi-3",
    colorer: llmColorer,
  })
  .register("mistral", {
    name: "Mistral",
    colorer: llmColorer,
  })
  .register("llama", {
    name: "Llama",
    colorer: llmColorer,
  })
  .register("gptneox", "gpt-neo-x", "gpt_neo_x", {
    name: "GPT-NeoX",
    colorer: llmColorer,
  })
  .register("mpt", {
    name: "MPT",
    colorer: llmColorer,
  })
  .register("replit", {
    name: "Replit",
    colorer: llmColorer,
  })
  .register("starcoder", {
    name: "StarCoder",
    colorer: llmColorer,
  })
  .register("falcon", {
    name: "Falcon",
    colorer: llmColorer,
  })
  .register("qwen", {
    name: "Qwen",
    colorer: llmColorer,
  })
  .register("qwen2", {
    name: "Qwen2",
    colorer: llmColorer,
  })
  .register("stablelm", {
    name: "StableLM",
    colorer: llmColorer,
  })
  .register("mamba", {
    name: "mamba",
    colorer: llmColorer,
  })
  .register("command-r", {
    name: "Command R",
    colorer: llmColorer,
  })
  .register("gemma", {
    name: "Gemma",
    colorer: llmColorer,
  })
  .register("gemma2", {
    name: "Gemma 2",
    colorer: llmColorer,
  })
  .register("deepseek2", {
    name: "DeepSeek 2",
    colorer: llmColorer,
  })
  .register("bert", {
    name: "BERT",
    colorer: embeddingColorer,
  })
  .register("nomic-bert", {
    name: "Nomic BERT",
    colorer: embeddingColorer,
  })
  .register("clip", {
    name: "CLIP",
    colorer: visionColorer,
  });</doc></concepts><src><doc title="Clipref" desc="docs page.">import { SimpleLogger } from "@lmstudio/lms-common";
import { z } from "zod";
import { SimpleFileData } from "./SimpleFileData.js";
import { cliPrefPath } from "./lmstudioPaths.js";

const cliPrefSchema = z.object({
  autoLaunchMinimizedWarned: z.boolean(),
  importWillMoveWarned: z.boolean().optional(),
  lastLoadedModels: z.array(z.string()).optional(),
  autoStartServer: z.boolean().optional(),
  fetchModelCatalog: z.boolean().optional(),
});

export type CliPref = z.infer<typeof cliPrefSchema>;

export async function getCliPref(logger?: SimpleLogger): Promise<SimpleFileData<CliPref>> {
  const defaultCliPref: CliPref = {
    autoLaunchMinimizedWarned: false,
    importWillMoveWarned: false,
    lastLoadedModels: [],
    autoStartServer: undefined,
    fetchModelCatalog: undefined,
  };
  const cliPref = new SimpleFileData(
    cliPrefPath,
    defaultCliPref,
    cliPrefSchema,
    new SimpleLogger("CliPref", logger),
  );
  await cliPref.init();
  return cliPref;
}</doc><doc title="Compareversions" desc="docs page.">const NUM_VERSION_COMPONENTS = 3;
const VERSION_REGEX = /^\d+(\.\d+){2}$/;

function parseVersion(version: string): number[] {
  if (!VERSION_REGEX.test(version)) {
    throw new Error(
      `Invalid version format: "${version}". Expected MAJOR.MINOR.PATCH with numbers only.`,
    );
  }

  return version.split(".").map(part => {
    const num = +part;
    if (!Number.isSafeInteger(num) || num < 0) {
      throw new Error(`Invalid component ${part} in ${version}`);
    }
    return num;
  });
}

export function compareVersions(a: string, b: string): 1 | -1 | 0 {
  const partsA = parseVersion(a);
  const partsB = parseVersion(b);

  for (let i = 0; i < NUM_VERSION_COMPONENTS; i++) {
    if (partsA[i] > partsB[i]) return 1;
    if (partsA[i] < partsB[i]) return -1;
  }

  return 0;
}</doc><doc title="Compareversions.Test" desc="docs page.">import { compareVersions } from "./compareVersions.js";

describe("Version Comparison Functions", () => {
  describe("compareVersions", () => {
    it("should return 1 when first version is newer", () => {
      expect(compareVersions("1.2.3", "1.2.2")).toBe(1);
      expect(compareVersions("2.0.0", "1.9.9")).toBe(1);
      expect(compareVersions("0.0.11", "0.0.2")).toBe(1);
    });

    it("should return -1 when second version is newer", () => {
      expect(compareVersions("1.2.2", "1.2.3")).toBe(-1);
      expect(compareVersions("1.9.9", "2.0.0")).toBe(-1);
      expect(compareVersions("0.0.2", "0.0.11")).toBe(-1);
    });

    it("should return 0 when versions are equal", () => {
      expect(compareVersions("1.2.3", "1.2.3")).toBe(0);
    });

    it("should handle versions with leading zeros", () => {
      expect(compareVersions("1.01.3", "1.1.3")).toBe(0);
      expect(compareVersions("1.02.3", "1.1.3")).toBe(1);
    });

    it("should throw error for invalid version format", () => {
      expect(() => compareVersions("invalid", "1.2.3")).toThrow();
      expect(() => compareVersions("1.2.3", "invalid")).toThrow();
    });

    it("should throw error for incomplete version format", () => {
      expect(() => compareVersions("2.0", "1.2.3")).toThrow();
      expect(() => compareVersions("1.2.3", "2.0")).toThrow();
      expect(() => compareVersions("1", "1.2.3")).toThrow();
    });
  });
});</doc><doc title="Confirm" desc="docs page.">import { Cleaner, makePromise } from "@lmstudio/lms-common";
import { createInterface, type Interface } from "readline/promises";

const interrupted = Symbol("interrupted");

interface AskQuestionOpts {
  rl?: Interface;
}

export async function askQuestion(prompt: string, opts: AskQuestionOpts = {}): Promise<boolean> {
  using cleaner = new Cleaner();
  let rl = opts.rl;
  if (rl === undefined) {
    const createdReadLine = createInterface({
      input: process.stdin,
      output: process.stderr,
    });
    cleaner.register(() => createdReadLine.close());
    rl = createdReadLine;
  }
  const { promise: sigintPromise, resolve: sigintResolve } = makePromise<typeof interrupted>();
  const sigintListener = () => {
    sigintResolve(interrupted);
  };
  rl.addListener("SIGINT", sigintListener);
  cleaner.register(() => {
    rl.removeListener("SIGINT", sigintListener);
  });
  let answer: boolean | undefined;
  do {
    const userResult = await Promise.race([rl.question(prompt + " (Y/N): "), sigintPromise]);
    if (userResult === interrupted) {
      console.info();
      return false;
    }
    if (userResult.toUpperCase() === "Y") {
      answer = true;
    } else if (userResult.toUpperCase() === "N") {
      answer = false;
    } else {
      process.stderr.write("Invalid selection. Please enter Y or N.\n");
    }
  } while (answer === undefined);
  return answer;
}</doc><doc title="Createclient" desc="docs page.">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";
import { exists } from "./exists.js";
import { appInstallLocationFilePath, lmsKey2Path } from "./lmstudioPaths.js";
import { type LogLevelArgs } from "./logLevel.js";
import { createRefinedNumberParser } from "./types/refinedNumber.js";

export const DEFAULT_SERVER_PORT: number = 1234;

/**
 * Checks if the HTTP server is running.
 */
export async function checkHttpServer(logger: SimpleLogger, port: number, host?: string) {
  const resolvedHost = host ?? "127.0.0.1";
  const url = `http://${resolvedHost}:${port}/lmstudio-greeting`;
  logger.debug(`Checking server at ${url}`);
  try {
    const abortController = new AbortController();
    setTimeout(() => abortController.abort(new Error("Connection timed out.")), 500).unref();
    const response = await fetch(url, { signal: abortController.signal });
    if (response.status !== 200) {
      logger.debug(`Status is not 200: ${response.status}`);
      return false;
    }
    const json = await response.json();
    if (json?.lmstudio !== true) {
      logger.debug(`Not an LM Studio server:`, json);
      return false;
    }
  } catch (e) {
    logger.debug(`Failed to check server:`, e);
    return false;
  }
  return true;
}

interface AppInstallLocation {
  path: string;
  argv: Array<string>;
  cwd: string;
}

/**
 * Adds create client options to a commander.js command
 */
export function addCreateClientOptions<
  Args extends any[],
  Opts extends OptionValues,
  GlobalOpts extends OptionValues,
>(command: Command<Args, Opts, GlobalOpts>): Command<Args, Opts & CreateClientArgs, GlobalOpts> {
  return command
    .addOption(
      new Option(
        "--host <host>",
        text`
          If you wish to connect to a remote LM Studio instance, specify the host here. Note that, in
          this case, lms will connect using client identifier "lms-cli-remote-<random chars>", which
          will not be a privileged client, and will restrict usage of functionalities such as
          "lms push".
        `,
      ).hideHelp(),
    )
    .addOption(
      new Option(
        "--port <port>",
        text`
          The port where LM Studio can be reached. If not provided and the host is set to "127.0.0.1"
          (default), the last used port will be used; otherwise, ${DEFAULT_SERVER_PORT} will be used.
        `,
      )
        .argParser(createRefinedNumberParser({ integer: true, min: 0, max: 65535 }))
        .hideHelp(),
    ) as Command<Args, Opts & CreateClientArgs, GlobalOpts>;
}

export interface CreateClientArgs {
  yes?: boolean;
  host?: string;
  port?: number;
}

async function isLocalServerAtPortLMStudioServerOrThrow(port: number) {
  const response = await fetch(`http://127.0.0.1:${port}/lmstudio-greeting`);
  if (response.status !== 200) {
    throw new Error("Status is not 200.");
  }
  const json = await response.json();
  if (json?.lmstudio !== true) {
    throw new Error("Not an LM Studio server.");
  }
  return port;
}

export async function tryFindLocalAPIServer(): Promise<number | null> {
  return await Promise.any(apiServerPorts.map(isLocalServerAtPortLMStudioServerOrThrow)).then(
    port => port,
    () => null,
  );
}

export async function wakeUpService(logger: SimpleLogger): Promise<boolean> {
  logger.info("Waking up LM Studio service...");
  const appInstallLocationPath = appInstallLocationFilePath;
  logger.debug(`Resolved appInstallLocationPath: ${appInstallLocationPath}`);
  try {
    const appInstallLocation = JSON.parse(
      await readFile(appInstallLocationPath, "utf-8"),
    ) as AppInstallLocation;
    logger.debug(`Read executable pointer:`, appInstallLocation);

    const args: Array<string> = [];
    const { path, argv, cwd } = appInstallLocation;
    if (argv[1] === ".") {
      // We are in development environment
      args.push(".");
    }
    // Also add the headless flag
    args.push("--run-as-service");

    logger.debug(`Spawning process:`, { path, args, cwd });

    const env = {
      ...(process.platform === "linux" ? { DISPLAY: ":0" } : {}),
      ...process.env,
    };

    const child = spawn(path, args, { cwd, detached: true, stdio: "ignore", env });
    child.unref();

    logger.debug(`Process spawned`);
    return true;
  } catch (e) {
    logger.debug(`Failed to launch application`, e);
    return false;
  }
}

export interface CreateClientOpts {}
const lmsKey = "<LMS-CLI-LMS-KEY>";

export async function createClient(
  logger: SimpleLogger,
  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("://")) {
    logger.error("Host should not include the protocol.");
    process.exit(1);
  } else if (host.includes(":")) {
    logger.error(`Host should not include the port number. Use ${chalk.yellow("--port")} instead.`);
    process.exit(1);
  }
  let auth: LMStudioClientConstructorOpts;
  if (isRemote) {
    // If connecting to a remote server, we will use a random client identifier.
    auth = {
      clientIdentifier: `lms-cli-remote-${randomBytes(18).toString("base64")}`,
    };
  } else {
    // Not remote. We need to check if this is a production build.
    if (
      lmsKey.startsWith("<") &&
      (process.env.LMS_FORCE_PROD === undefined || process.env.LMS_FORCE_PROD === "")
    ) {
      // lmsKey not injected and we did not force prod, this is not a production build.
      logger.warnText`
        You are using a development build of lms-cli. Privileged features such as "lms push" will
        not work.
      `;
      auth = {
        clientIdentifier: "lms-cli-dev",
      };
    } else {
      if (await exists(lmsKey2Path)) {
        const lmsKey2 = (await readFile(lmsKey2Path, "utf-8")).trim();
        auth = {
          clientIdentifier: "lms-cli",
          clientPasskey: lmsKey + lmsKey2,
        };
      } else {
        // This case will happen when the CLI is the production build, yet the local LM Studio has
        // not been run yet (so no lms-key-2 file). In this case, we will just use a dummy client
        // identifier as we will soon try to wake up the service and refetch the key.
        auth = {
          clientIdentifier: "lms-cli",
        };
      }
    }
  }
  if (port === undefined && host === "127.0.0.1") {
    // We will now attempt to connect to the local API server.
    const localPort = await tryFindLocalAPIServer();

    if (localPort !== null) {
      const baseUrl = `ws://${host}:${localPort}`;
      logger.debug(`Found local API server at ${baseUrl}`);
      return new LMStudioClient({ baseUrl, logger, ...auth });
    }

    // At this point, the user wants to access the local LM Studio, but it is not running. We will
    // wake up the service and poll the API server until it is up.

    await wakeUpService(logger);

    // Polling

    for (let i = 1; i <= 60; i++) {
      await new Promise(resolve => setTimeout(resolve, 1000));
      logger.debug(`Polling the API server... (attempt ${i})`);
      const localPort = await tryFindLocalAPIServer();
      if (localPort !== null) {
        const baseUrl = `ws://${host}:${localPort}`;
        logger.debug(`Found local API server at ${baseUrl}`);

        if (auth.clientIdentifier === "lms-cli") {
          // We need to refetch the lms key due to the possibility of a new key being generated.
          const lmsKey2 = (await readFile(lmsKey2Path, "utf-8")).trim();
          auth = {
            ...auth,
            clientPasskey: lmsKey + lmsKey2,
          };
        }

        return new LMStudioClient({ baseUrl, logger, ...auth });
      }
    }

    logger.error("");
  }

  if (port === undefined) {
    port = DEFAULT_SERVER_PORT;
  }

  logger.debug(`Connecting to server at ${host}:${port}`);
  if (!(await checkHttpServer(logger, port, host))) {
    logger.error(
      text`
        The server does not appear to be running at ${host}:${port}. Please make sure the server
        is running and accessible at the specified address.
      `,
    );
  }
  const baseUrl = `ws://${host}:${port}`;
  logger.debug(`Found server at ${port}`);
  return new LMStudioClient({
    baseUrl,
    logger,
    ...auth,
  });
}</doc><doc title="Downloadpbupdater" desc="docs page.">import { text } from "@lmstudio/lms-common";
import { type DownloadProgressUpdate } from "@lmstudio/sdk";
import { formatSizeBytes1000 } from "./formatSizeBytes1000.js";
import { type ProgressBar } from "./ProgressBar.js";

function formatRemainingTime(timeSeconds: number) {
  const seconds = timeSeconds % 60;
  const minutes = Math.floor(timeSeconds / 60) % 60;
  const hours = Math.floor(timeSeconds / 3600);
  if (hours > 0) {
    return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
  }
  return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
}

/**
 * Given a progress bar pb, return a function that updates the progress bar with the given
 * DownloadProgressUpdate.
 */
export function createDownloadPbUpdater(pb: ProgressBar) {
  let longestDownloadedBytesStringLength = 6;
  let longestTotalBytesStringLength = 6;
  let longestSpeedBytesPerSecondStringLength = 6;
  return ({ downloadedBytes, totalBytes, speedBytesPerSecond }: DownloadProgressUpdate) => {
    const downloadedBytesString = formatSizeBytes1000(downloadedBytes);
    if (downloadedBytesString.length > longestDownloadedBytesStringLength) {
      longestDownloadedBytesStringLength = downloadedBytesString.length;
    }
    const totalBytesString = formatSizeBytes1000(totalBytes);
    if (totalBytesString.length > longestTotalBytesStringLength) {
      longestTotalBytesStringLength = totalBytesString.length;
    }
    const speedBytesPerSecondString = formatSizeBytes1000(speedBytesPerSecond);
    if (speedBytesPerSecondString.length > longestSpeedBytesPerSecondStringLength) {
      longestSpeedBytesPerSecondStringLength = speedBytesPerSecondString.length;
    }
    const timeLeftSeconds = Math.round((totalBytes - downloadedBytes) / speedBytesPerSecond);
    pb.setRatio(
      downloadedBytes / totalBytes,
      text`
        ${downloadedBytesString.padStart(longestDownloadedBytesStringLength)} /
        ${totalBytesString.padStart(longestTotalBytesStringLength)} |
        ${speedBytesPerSecondString.padStart(longestSpeedBytesPerSecondStringLength)}/s | ETA
        ${formatRemainingTime(timeLeftSeconds)}
      `,
    );
  };
}</doc><doc title="Ensureauthenticated" desc="docs page.">import { makePromise, makeTitledPrettyError, text, type SimpleLogger } from "@lmstudio/lms-common";
import { type LMStudioClient } from "@lmstudio/sdk";
import chalk from "chalk";

export async function ensureAuthenticated(
  client: LMStudioClient,
  logger: SimpleLogger,
  { yes = false }: { yes?: boolean } = {},
) {
  const { promise, resolve, reject } = makePromise<void>();
  client.repository
    .ensureAuthenticated({
      onAuthenticationUrl: url => {
        if (yes) {
          reject(
            makeTitledPrettyError(
              "Authentication required",
              text`
                This operation requires you to be authenticated. Inline authentication disabled due
                to ${chalk.yellow("--yes")} flag. Please use ${chalk.yellow("lms auth")}
                to authenticate before running this command again.
              `,
              url,
            ),
          );
        } else {
          logger.info("Authentication required. Please visit the following URL to authenticate:");
          logger.info();
          logger.info(chalk.green(`    ${url}`));
          logger.info();
        }
      },
    })
    .then(resolve, reject);

  await promise;
}</doc><doc title="Exists" desc="docs page.">import { access } from "fs/promises";

export async function exists(path: string) {
  try {
    await access(path);
    return true;
  } catch {
    return false;
  }
}</doc><doc title="Filedata" desc="docs page.">import {
  isAvailable,
  Signal,
  type Setter,
  type SimpleLogger,
  type StripNotAvailable,
} from "@lmstudio/lms-common";
import { existsSync, writeFileSync } from "fs";
import { mkdir, readFile, watch } from "fs/promises";
import path from "path";
import { type ZodSchema } from "zod";

const fileDataGlobalCache: Map<string, FileData<any, any>> = new Map();

export type InitializationState =
  | {
      type: "notStarted";
    }
  | {
      type: "initializing";
      promise: Promise<void>;
    }
  | {
      type: "initialized";
    };

export class FileData<TData, TSerialized> {
  public get dataSignal(): Signal<TData> {
    if (this.initializationState.type !== "initialized") {
      throw new Error(
        "FileData is not initialized yet, cannot access dataSignal. (Must call init() first)",
      );
    }
    return this.internalDataSignal;
  }
  private readonly internalDataSignal!: Signal<TData>;
  private readonly setData!: Setter<TData>;
  private lastWroteString: string | null = null;
  private initializationState: InitializationState = { type: "notStarted" };
  public constructor(
    private readonly filePath: string,
    private readonly defaultData: TData,
    private readonly serializer: (data: TData) => TSerialized,
    private readonly deserializer: (serialized: TSerialized) => TData,
    private readonly serializedSchema: ZodSchema<TSerialized>,
    private readonly logger?: SimpleLogger,
  ) {
    if (fileDataGlobalCache.has(filePath)) {
      logger?.debug("FileData already exists in cache, returning existing instance.");
      return fileDataGlobalCache.get(filePath) as FileData<TData, TSerialized>;
    }
    [this.internalDataSignal, this.setData] = Signal.create(defaultData);
    fileDataGlobalCache.set(filePath, this);
  }

  public async init() {
    if (this.initializationState.type === "initializing") {
      await this.initializationState.promise;
      return;
    }
    if (this.initializationState.type === "initialized") {
      return;
    }
    const initPromise = this.initInternal();
    this.initializationState = { type: "initializing", promise: initPromise };
    await initPromise;
    this.initializationState = { type: "initialized" };
  }

  private async initInternal() {
    this.logger?.debug("Initializing FileData");
    const dir = path.dirname(this.filePath);
    await mkdir(dir, { recursive: true });
    let data: TData | null = null;
    if (!existsSync(this.filePath)) {
      this.logger?.debug("File does not exist, writing default data");
      this.writeData(this.defaultData);
    } else {
      data = await this.readData();
    }
    if (data === null) {
      data = this.defaultData;
    }
    this.setData(data as StripNotAvailable<TData>);
    this.startWatcher().catch(e => {
      this.logger?.error(`Watcher failed: ${e}`);
    });
  }

  private async startWatcher() {
    const watcher = watch(this.filePath, {
      persistent: false,
    });
    for await (const event of watcher) {
      if (event.eventType === "change") {
        this.logger?.debug("File changed, reading data");
        const data: TData | null = await this.readData();
        if (data !== null && isAvailable(data)) {
          this.setData(data as any);
        }
      }
    }
  }

  private async readData(): Promise<TData | null> {
    try {
      const content = await readFile(this.filePath, "utf-8");
      if (content === this.lastWroteString) {
        this.logger?.debug("File content is the same as last written, skipping read");
        return null;
      }
      const json = JSON.parse(content);
      const parsed = this.serializedSchema.parse(json);
      const data = this.deserializer(parsed);
      return data;
    } catch (e) {
      this.logger?.error(`Error reading data from file: ${e}`);
      return null;
    }
  }

  private writeData(data: TData) {
    const serialized = this.serializer(data);
    const json = JSON.stringify(serialized, null, 2);
    if (json === this.lastWroteString) {
      return;
    }
    this.lastWroteString = json;
    try {
      writeFileSync(this.filePath, json);
    } catch (e) {
      this.logger?.error(`Error writing data to file: ${e}`);
    }
  }

  public set(data: TData) {
    if (!isAvailable(data)) {
      throw new Error("Cannot set data to NOT_AVAILABLE");
    }
    this.setData(data);
    this.writeData(this.dataSignal.get());
  }

  public setWithProducer(producer: (draft: TData) => void) {
    this.setData.withProducer(producer);
    this.writeData(this.dataSignal.get());
  }

  public get() {
    return this.dataSignal.get();
  }
}</doc><doc title="Findprojectfolder" desc="docs page.">import { type SimpleLogger } from "@lmstudio/lms-common";
import { access } from "fs/promises";
import { dirname, join, resolve } from "path";

/**
 * From the given folder, recursively travels back up, until finds one folder that contains a file
 * with the given name.
 */
export async function recursiveFindAncestorFolderWithFile(
  logger: SimpleLogger,
  fileName: string,
  cwd: string,
) {
  let currentDir = resolve(cwd);

  let maximumDepth = 20;
  while (maximumDepth > 0) {
    maximumDepth--;
    const manifestPath = join(currentDir, fileName);
    logger.debug("Trying to access", manifestPath);
    try {
      await access(manifestPath);
      logger.debug(`Found ${fileName} 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 ${fileName}`);
  return null;
}

/**
 * Try to find the ancestor folder with a manifest.json file. If it does not exist, print an error
 * message and exit the process.
 */
export async function findProjectFolderOrExit(logger: SimpleLogger, cwd: string) {
  const projectFolder = await recursiveFindAncestorFolderWithFile(logger, "manifest.json", cwd);
  if (projectFolder === null) {
    logger.errorText`Could not find the project folder. Please invoke this command in a folder with a
      manifest.json file.
      \n       To create an empty plugin, use the \`lms create\` command, or create a new plugin in
      LM Studio.
    `;
    process.exit(1);
  }
  return projectFolder;
}</doc></src></project>
