Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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 };
},
};
Loading
Loading