Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ coverage
.env
.DS_Store
.turbo

plans/
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"@lmstudio/sdk": "^1.5.0",
"chalk": "^4.1.2",
"columnify": "^1.6.0",
"cross-spawn": "^7.0.6",
"fast-glob": "^3.3.2",
"fuzzy": "^0.1.3",
"ink": "^6.5.1",
Expand All @@ -42,6 +43,7 @@
},
"devDependencies": {
"@types/columnify": "^1.5.4",
"@types/cross-spawn": "^6.0.6",
"@types/node": "^20.12.5",
"@types/react": "^19.2.0",
"@typescript-eslint/eslint-plugin": "^6.20.0",
Expand Down
15 changes: 9 additions & 6 deletions src/Spinner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ export class Spinner {
private timer: NodeJS.Timeout | null = null;
private spinnerIndex = 0;

public constructor(private text: string) {
public constructor(
private text: string,
private readonly outputStream: NodeJS.WriteStream = process.stdout,
) {
this.timer = setInterval(() => {
this.spinnerIndex++;
this.refresh();
Expand All @@ -27,8 +30,8 @@ export class Spinner {
clearInterval(this.timer);
this.timer = null;
}
process.stdout.write("\r\x1B[K");
process.stdout.write("\x1B[?25h");
this.outputStream.write("\r\x1B[K");
this.outputStream.write("\x1B[?25h");
}

public stopWithoutClear() {
Expand All @@ -40,7 +43,7 @@ export class Spinner {
clearInterval(this.timer);
this.timer = null;
}
process.stdout.write("\x1B[?25h");
this.outputStream.write("\x1B[?25h");
}

public start() {
Expand All @@ -60,8 +63,8 @@ export class Spinner {
}

private refresh() {
process.stdout.write("\x1B[?25l");
process.stdout.write(
this.outputStream.write("\x1B[?25l");
this.outputStream.write(
`\r${this.text} ${SPINNER_FRAMES[this.spinnerIndex % SPINNER_FRAMES.length]}`,
);
}
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { dev } from "./subcommands/dev/index.js";
import { flags } from "./subcommands/flags.js";
import { get } from "./subcommands/get.js";
import { importCmd } from "./subcommands/importCmd.js";
import { launch } from "./subcommands/launch/index.js";
import { link } from "./subcommands/link/index.js";
import { ls, ps } from "./subcommands/list.js";
import { load } from "./subcommands/load.js";
Expand Down Expand Up @@ -167,6 +168,7 @@ Join our Discord: ${chalk.blue("https://discord.gg/lmstudio")}`,

addCommandsGroup("Local models", [chat, get, load, unload, ls, ps, importCmd], "#22D3EE");
addCommandsGroup("Serve", [server, log], "#34D399");
addCommandsGroup("Launch (Beta)", [launch], "#FB923C");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Let launch forward version flags

Registering launch as a pass-through command here exposes it to the pre-Commander version check below (commandArguments.includes("-v") || commandArguments.includes("--version")). For any forwarded tool version flag, such as lms launch codex -- --version or lms launch aider -- -v, the process exits before launch can parse the -- separator, so the advertised verbatim forwarding path cannot invoke the tool.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d37632c.

Right — the pre-Commander shortcut did a blunt commandArguments.includes("-v"|"--version") over all args, so lms launch codex -- --version (or lms launch aider -- -v) matched and printed the lms version + exited before launch ever parsed --, defeating the verbatim forwarding.

Change (src/index.ts): the shortcut now only fires for a -v/--version that appears before the first subcommand token (commandArguments.slice(0, firstSubcommandIndex)). lms -v / lms --version still work; after a subcommand the flag belongs to it, and launch forwards everything past -- to the wrapped tool. As a bonus this also stops lms <anysub> --version from masquerading as the lms version in general.

addCommandsGroup("Remote Instances", [link], "#818CF8");
addCommandsGroup("Runtime", [runtime], "#C084FC");
addCommandsGroup("Develop & Publish (Beta)", [clone, push, dev, login, logout, whoami], "#F9A8D4");
Expand Down
44 changes: 44 additions & 0 deletions src/subcommands/launch/adapters/aider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { writeFile } from "fs/promises";
import { join } from "path";
import { type LaunchContext, type ToolAdapter } from "../types.js";

const COMMAND = "aider";

async function writeModelMetadataFile(ctx: LaunchContext): Promise<string> {
const metadataPath = join(ctx.workDir, ".aider.model.metadata.json");
const metadata = {
[`lm_studio/${ctx.model}`]: {
max_input_tokens: ctx.contextLength,
litellm_provider: "lm_studio",
mode: "chat",
},
};
await writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf-8");
return metadataPath;
}

/**
* Aider, via its LM Studio-native provider path. Env for the endpoint, CLI arg for model
* selection (no env equivalent exists). Verified against
* https://aider.chat/docs/llms/openai-compat.html
*/
export const aider: ToolAdapter = {
name: "aider",
displayName: "Aider",
command: COMMAND,
install: { pip: "aider-chat", url: "https://aider.chat/docs/llms/openai-compat.html" },
supportsContextHint: true,
async prepare(ctx) {
const env: Record<string, string> = {
LM_STUDIO_API_BASE: ctx.openaiBaseUrl,
// Must be non-empty -- aider's OpenAI-compatible client rejects an empty bearer token.
LM_STUDIO_API_KEY: ctx.apiKey !== "" ? ctx.apiKey : "lmstudio",
};
const args = ["--model", `lm_studio/${ctx.model}`];
if (ctx.contextLength !== undefined) {
const metadataPath = await writeModelMetadataFile(ctx);
args.push("--model-metadata-file", metadataPath);
}
return { command: COMMAND, args, env };
},
};
45 changes: 45 additions & 0 deletions src/subcommands/launch/adapters/claude.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { type ToolAdapter } from "../types.js";

const COMMAND = "claude";
const CLAUDE_FIRST_PARTY_MODEL_ID_RE = /^claude-/i;

/**
* Claude Code. Env-only: base URL is the bare origin (Claude Code appends `/v1/messages` itself).
* Verified against https://code.claude.com/docs/en/env-vars and https://lmstudio.ai/blog/claudecode.
*/
export const claude: ToolAdapter = {
name: "claude",
aliases: ["claude-code"],
displayName: "Claude Code",
command: COMMAND,
install: { npm: "@anthropic-ai/claude-code", url: "https://lmstudio.ai/blog/claudecode" },
supportsContextHint: true,
async prepare(ctx) {
const env: Record<string, string> = {
ANTHROPIC_BASE_URL: ctx.origin, // NOT ctx.openaiBaseUrl -- no /v1 suffix here
ANTHROPIC_AUTH_TOKEN: ctx.apiKey,
ANTHROPIC_MODEL: ctx.model,
// Pin all four tiers so background/subagent calls don't target a nonexistent Anthropic
// model id.
ANTHROPIC_DEFAULT_OPUS_MODEL: ctx.model,
ANTHROPIC_DEFAULT_SONNET_MODEL: ctx.model,
ANTHROPIC_DEFAULT_HAIKU_MODEL: ctx.model,
ANTHROPIC_DEFAULT_FABLE_MODEL: ctx.model,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Override Claude subagent model too

If the caller already has CLAUDE_CODE_SUBAGENT_MODEL exported, spawnToolAndWait preserves it because only these keys are overlaid on process.env, and Claude Code documents that variable as the subagent model override. In that environment, subagents can still target a cloud/nonexistent Anthropic model even though the main and tier defaults are pinned here; set this variable to the local model/inherit value or explicitly clear it for the child.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a38dc3c.

Confirmed against code.claude.com/docs/en/model-configCLAUDE_CODE_SUBAGENT_MODEL overrides subagent/agent-team model resolution ahead of the tier defaults, and since spawnToolAndWait spawns with { ...process.env, ...extraEnv }, a value already exported in the caller's shell survived untouched and could point subagents at a cloud/nonexistent Anthropic model.

Change (adapters/claude.ts): added CLAUDE_CODE_SUBAGENT_MODEL: ctx.model alongside the four tier pins, so subagents resolve to the local model too. Test updated.

};
const notes: string[] = [];
if (ctx.contextLength !== undefined) {
env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = String(ctx.contextLength);
}
if (CLAUDE_FIRST_PARTY_MODEL_ID_RE.test(ctx.model)) {
notes.push(
`Model id "${ctx.model}" starts with "claude-"; Claude Code will assume a first-party ` +
`200K window and ignore CLAUDE_CODE_AUTO_COMPACT_WINDOW. Load with ` +
`"lms load --identifier <other-name>" to avoid this.`,
);
}
// args is empty on purpose: any passthrough "--model" the user typed after "claude" is
// forwarded untouched by index.ts, and Claude Code's own --model flag takes precedence over
// the env vars above, so nothing here needs to inject or dedupe a --model arg.
return { command: COMMAND, args: [], env, notes };
},
};
51 changes: 51 additions & 0 deletions src/subcommands/launch/adapters/codex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { type ToolAdapter } from "../types.js";

const COMMAND = "codex";
// A synthetic provider id, scoped to this invocation only (never written to any config file).
const PROVIDER_ID = "lmslaunch";

/**
* Codex CLI. Ships the explicit custom-provider `-c` override form (portable across versions)
* rather than assuming a built-in "lmstudio"/"oss" provider exists in the installed release.
* Uses wire_api=responses: current Codex removed Chat Completions support (Feb 2026) and only
* speaks the Responses API, which LM Studio serves at /v1/responses. See the note for the legacy
* chat fallback (older Codex + older LM Studio).
*/
export const codex: ToolAdapter = {
name: "codex",
displayName: "Codex CLI",
command: COMMAND,
install: { npm: "@openai/codex" },
supportsContextHint: true,
async prepare(ctx) {
const args = [
"-c",
`model_providers.${PROVIDER_ID}.base_url=${ctx.openaiBaseUrl}`,
"-c",
`model_providers.${PROVIDER_ID}.wire_api=responses`,
"-c",
// Names the env var Codex reads the bearer token from. Without env_key the custom provider
// sends no Authorization header, so `--api-key` can't authenticate a secured LM Studio endpoint.
`model_providers.${PROVIDER_ID}.env_key=OPENAI_API_KEY`,
"-c",
`model_provider=${PROVIDER_ID}`,
"-c",
`model=${ctx.model}`,
"-c",
"sandbox_mode=workspace-write",
];
if (ctx.contextLength !== undefined) {
args.push("-c", `model_context_window=${ctx.contextLength}`);
}
const env: Record<string, string> = {
// Referenced by model_providers.<id>.env_key above; Codex forwards it as the bearer token.
OPENAI_API_KEY: ctx.apiKey,
};
const notes = [
`Temporary Codex provider ("${PROVIDER_ID}") using wire_api=responses (current Codex dropped ` +
`Chat Completions; LM Studio serves /v1/responses). On a pre-2026 Codex without Responses ` +
`support, override after "--": -c model_providers.${PROVIDER_ID}.wire_api=chat`,
];
return { command: COMMAND, args, env, notes };
},
};
26 changes: 26 additions & 0 deletions src/subcommands/launch/adapters/copilot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { type ToolAdapter } from "../types.js";

const COMMAND = "copilot";

/**
* GitHub Copilot CLI (the standalone `@github/copilot` package, NOT `gh copilot`). Env-only.
* Verified names against https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/use-byok-models
*/
export const copilot: ToolAdapter = {
name: "copilot",
displayName: "GitHub Copilot CLI",
command: COMMAND,
install: { npm: "@github/copilot" },
// No verified per-tool context knob: context lives entirely in the model load (layer 1).
supportsContextHint: false,
async prepare(ctx) {
const env: Record<string, string> = {
COPILOT_PROVIDER_BASE_URL: ctx.openaiBaseUrl,
COPILOT_MODEL: ctx.model,
COPILOT_PROVIDER_API_KEY: ctx.apiKey,
COPILOT_PROVIDER_TYPE: "openai",
COPILOT_OFFLINE: "true", // do not contact GitHub servers
};
return { command: COMMAND, args: [], env };
},
};
136 changes: 136 additions & 0 deletions src/subcommands/launch/adapters/droid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { confirm } from "@inquirer/prompts";
import chalk from "chalk";
import { mkdir, readFile, rm, writeFile } from "fs/promises";
import { homedir } from "os";
import { dirname, join } from "path";
import { exists } from "../../../exists.js";
import { runPromptWithExitHandling } from "../../../prompt.js";
import { UserInputError } from "../../../types/UserInputError.js";
import { type ToolAdapter } from "../types.js";

const COMMAND = "droid";
// Stable key so re-running "lms launch droid" updates the same entry instead of duplicating it.
const DISPLAY_NAME = "LM Studio (lms launch)";

export interface DroidCustomModel {
displayName: string;
model: string;
baseUrl: string;
apiKey: string;
provider: string;
}

export interface DroidSettings {
customModels?: DroidCustomModel[];
[key: string]: unknown;
}

function settingsFilePath(): string {
return join(homedir(), ".factory", "settings.json");
}

/** Exported for unit testing; merges idempotently by `displayName` (last write wins). */
export function mergeDroidSettings(existing: DroidSettings, entry: DroidCustomModel): DroidSettings {
const customModels = (existing.customModels ?? []).filter(
model => model.displayName !== entry.displayName,
);
customModels.push(entry);
return { ...existing, customModels };
}

/**
* Factory's `droid` CLI. Model selection lives in `~/.factory/settings.json`, a real file the
* user's Factory installation also reads/writes, so we back up the original content, write our
* entry keyed by a stable displayName (idempotent across re-runs), confirm before touching it
* unless -y, and restore the original content on exit via `cleanup` (kept in place under
* `--print-env`, where the emitted command still needs it).
*
* Factory's BYOK schema has no context-window field (only `maxOutputTokens`, the completion cap),
* so this adapter conveys no context hint -- `supportsContextHint` is false and the model's loaded
* window is simply the effective one.
*/
export const droid: ToolAdapter = {
name: "droid",
displayName: "Factory (droid)",
command: COMMAND,
install: { note: "Install the Factory CLI (droid) from your Factory account/dashboard." },
supportsContextHint: false,
async prepare(ctx) {
const filePath = settingsFilePath();
const fileExisted = await exists(filePath);
const originalRaw = fileExisted ? await readFile(filePath, "utf-8") : undefined;

let existingSettings: DroidSettings = {};
if (originalRaw !== undefined) {
try {
existingSettings = JSON.parse(originalRaw) as DroidSettings;
} catch {
throw new UserInputError(
`Could not parse ${filePath} as JSON. Please fix or remove the file, then try again.`,
);
}
}

const entry: DroidCustomModel = {
displayName: DISPLAY_NAME,
model: ctx.model,
baseUrl: ctx.openaiBaseUrl,
apiKey: ctx.apiKey,
provider: "generic-chat-completion-api",
};
// Deliberately no maxOutputTokens: it is Factory's output-completion cap, not a context-window
// hint, so mapping the model's context length onto it would advertise a bogus response budget.

if (!ctx.yes) {
if (process.stdin.isTTY !== true) {
// No TTY means we cannot prompt. Rewriting a real user file -- one that --print-env
// deliberately leaves in place (un-reverted) -- must not happen without explicit consent,
// so fail here instead of silently proceeding as if --yes had been passed.
throw new UserInputError(
`Launching "droid" adds a custom model entry to ${filePath}. In a non-interactive shell ` +
`there is no way to confirm; re-run with -y/--yes to approve modifying it, or run in an ` +
`interactive terminal.`,
);
}
// Prompt context goes to stderr (like the confirm prompt below), so it never lands on stdout
// where `eval "$(lms launch droid --print-env)"` would try to run this human text as shell.
console.error();
console.error(chalk.dim(`! "droid" reads its model list from ${filePath}.`));
const proceed = await runPromptWithExitHandling(() =>
confirm(
{ message: `Add/update the "${DISPLAY_NAME}" entry in ${filePath}?`, default: true },
{ output: process.stderr },
),
);
if (!proceed) {
throw new UserInputError(`Aborted: declined to modify ${filePath}.`);
}
}

const merged = mergeDroidSettings(existingSettings, entry);
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, JSON.stringify(merged, null, 2), "utf-8");

const cleanup = async () => {
if (originalRaw !== undefined) {
await writeFile(filePath, originalRaw, "utf-8");
} else {
await rm(filePath, { force: true });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve Factory settings updates during cleanup

When a non---print-env droid launch exits, this restores the entire original ~/.factory/settings.json snapshot (or deletes the file if it did not exist). If the launched droid process or the user changes Factory settings while that session is running, those later writes are lost on cleanup because this code writes the old snapshot back wholesale. Remove only the LM Studio (lms launch) entry from the current file instead, so unrelated settings changes survive.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4ca0c41.

Right — cleanup wrote the whole pre-launch snapshot back (or deleted the file), so anything droid or the user changed in ~/.factory/settings.json during the session — other custom models, unrelated settings — was clobbered on exit.

Change (adapters/droid.ts): cleanup now re-reads the current file and strips only our "LM Studio (lms launch)" entry via a new removeDroidSettingsEntry helper (symmetric to mergeDroidSettings), so concurrent changes survive. It drops the file only when we created it and nothing else remains, and falls back to the wholesale snapshot restore solely when the current file is missing/unparseable (surgical removal impossible). Added unit tests covering: removes only our entry while keeping session-added models, preserves changed top-level keys, drops customModels when it was only ours, and no-ops when our entry is absent.

};

return {
command: COMMAND,
args: [],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Select the Droid model when launching

When the forwarded Droid invocation is non-interactive, such as lms launch droid --model X -- exec ..., this adapter writes the custom model entry but returns no -m/--model argument, so Droid can keep using its current/default model instead of the LM Studio model that lms launch resolved. Factory's CLI reference documents model selection through -m, --model, so the launch wrapper should pass the injected model id/name (while still allowing user args to override it if needed).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — the underlying concern is right (the settings entry alone doesn't make droid use the model), but passing --model isn't safe here, so I've addressed it differently.

Factory's droid --model does not reliably accept custom BYOK models: it expects a generated id like custom:<DisplayName-dashified>-<index> (not the plain model id or the displayName), and there's an open bug — Factory-AI/factory#787 — where droid exec --model rejects every custom model id (all three tried forms: the custom:...-index id, the displayName, and an alternate) with "Invalid model". Per that issue only sessionDefaultSettings.model works via the flag, and the custom:...-index id is undocumented and index-dependent, so we can't reliably reproduce it.

So injecting --model <id> would risk breaking lms launch droid with an "Invalid model" error rather than fixing it. Instead (a38dc3c) I added a note directing the user to select the entry through droid's own /model picker — the documented selection path (per the Groq/Factory BYOK guides). If #787 lands and the id format is documented, I'm glad to inject it then; meanwhile user -- <args> still forward verbatim, so anyone with a known-working custom:... id can pass -- --model custom:... themselves.

env: {},
notes: [
ctx.printEnv
? `Wrote a "${DISPLAY_NAME}" entry to ${filePath} and left it in place so the printed ` +
`command resolves the model; it is NOT auto-reverted. Remove it yourself, or re-run ` +
`without --print-env to have it reverted on exit.`
: `Wrote a temporary "${DISPLAY_NAME}" entry to ${filePath}; it will be reverted on exit.`,
],
cleanup,
};
},
};
9 changes: 9 additions & 0 deletions src/subcommands/launch/adapters/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { type ToolAdapter } from "../types.js";
import { aider } from "./aider.js";
import { claude } from "./claude.js";
import { codex } from "./codex.js";
import { copilot } from "./copilot.js";
import { droid } from "./droid.js";
import { opencode } from "./opencode.js";

export const adapters: ToolAdapter[] = [claude, codex, copilot, aider, opencode, droid];
Loading
Loading