mirror of
https://github.com/morluto/rea.git
synced 2026-11-14 10:43:30 +00:00
feat(setup): register LM Studio during setup (#8611)
* feat(setup): register LM Studio during setup Register rea in mcp.json in the LM Studio home through a new lm_studio client. The home follows LM Studio's own lookup, read-only: the path in ~/.lmstudio-home-pointer, else an existing ~/.cache/lm-studio, else ~/.lmstudio. An unreadable or relative pointer reports a path error instead of a guessed home. LM Studio parses mcp.json with strict JSON.parse, so comments, trailing commas and a BOM are rejected before any backup or write. The entry carries command, args and env with no type or timeout. Local models have small context windows, so the registration selects REA_MCP_INPUT_SCHEMA_PROFILE=compact unless a profile is set explicitly, and doctor reports an entry without a profile as stale. One predicate now owns that default for Kimi Code and LM Studio. * fix(doctor): resolve provider budgets for LM Studio as setup does
This commit is contained in:
1 parent
103684bce1
commit
398eb0d1f1
9 files changed
+295
-16
No files matched your search
@@ -310,6 +310,22 @@ Kimi Code also discovers personal skills in `~/.agents/skills` and project-local
|
||||
and verifies the matching skill files through the normal shared-skill flow.
|
||||
Start a new Kimi Code session to pick up MCP and skill changes.
|
||||
|
||||
## LM Studio
|
||||
|
||||
For [LM Studio](https://lmstudio.ai/docs/app/mcp), setup writes `mcpServers.rea`
|
||||
into `mcp.json` in the LM Studio home directory. Setup finds that directory as
|
||||
LM Studio does: the path stored in `~/.lmstudio-home-pointer`, otherwise
|
||||
`~/.cache/lm-studio` when it exists, otherwise `~/.lmstudio`. Setup only reads
|
||||
the pointer file. LM Studio parses `mcp.json` as strict JSON, so setup rejects
|
||||
a file with comments, trailing commas, or a byte order mark without a backup
|
||||
or a write. The entry carries `command`, `args`, and `env`, and setup keeps
|
||||
every other server. Local models often have small context windows, so setup
|
||||
selects `REA_MCP_INPUT_SCHEMA_PROFILE=compact` unless the stored entry or the
|
||||
setup environment selects a profile explicitly. Doctor reports an entry with no
|
||||
explicit profile as stale, as for Kimi Code. LM Studio 0.4.26 on macOS loaded
|
||||
this registration, and a local model called an REA tool through it. Linux and
|
||||
Windows remain unverified.
|
||||
|
||||
## Pi
|
||||
|
||||
For [Pi](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/mcp.md),
|
||||
|
||||
@@ -654,6 +654,7 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s
|
||||
| Factory Droid | `droid` |
|
||||
| Trae | `trae` |
|
||||
| WorkBuddy | `workbuddy` |
|
||||
| LM Studio | `lm_studio` |
|
||||
| ZCode | `zcode` |
|
||||
| DeepSeek Harness | `dsh` |
|
||||
| FreeBuff | `freebuff` |
|
||||
@@ -663,6 +664,8 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s
|
||||
`freebuff` requires `rea-agents@6.8.0` or later. Older releases reject
|
||||
`rea setup --client freebuff` as an unsupported client.
|
||||
|
||||
`lm_studio` requires a release after 6.8.0.
|
||||
|
||||
See [client configuration and troubleshooting](client-configuration.md) for
|
||||
configuration paths, profiles, trust policies, and schema-limit workarounds.
|
||||
|
||||
|
||||
@@ -152,9 +152,10 @@ const parseDocument = (
|
||||
// Pi loads `mcp.json` with a strict `JSON.parse` and no BOM tolerance, so
|
||||
// comments, trailing commas, a BOM, or an empty existing file must be
|
||||
// rejected instead of registering into a document the client cannot load.
|
||||
// Kimi also reads mcp.json with JSON.parse and an empty-file fallback.
|
||||
if (format === "pi" || format === "kimi") {
|
||||
if (format === "kimi" && text.trim().length === 0) return {};
|
||||
// Kimi and LM Studio also read mcp.json with JSON.parse; an empty file holds
|
||||
// nothing to preserve.
|
||||
if (format === "pi" || format === "kimi" || format === "lm_studio") {
|
||||
if (format !== "pi" && text.trim().length === 0) return {};
|
||||
return requireObject(JSON.parse(text), "The configuration root");
|
||||
}
|
||||
// An empty file, which some clients create before any server is added,
|
||||
@@ -1748,7 +1749,8 @@ export const clientRegistrationEntry = (
|
||||
: { environment: { ...environment } }),
|
||||
};
|
||||
case "kimi":
|
||||
// Kimi infers stdio from command; it uses `transport`, not `type`.
|
||||
case "lm_studio":
|
||||
// Both infer stdio from command; Kimi uses `transport`, not `type`.
|
||||
return {
|
||||
command: executable,
|
||||
args,
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import {
|
||||
clientEvidencePaths,
|
||||
clientOwnershipEvidencePaths,
|
||||
compactInputSchemaByDefault,
|
||||
manualRegistrationRemediation,
|
||||
supportedClients,
|
||||
} from "./SupportedClients.js";
|
||||
@@ -279,12 +280,14 @@ export const readClientRegistrationInspections = async (
|
||||
}
|
||||
const registration = parsedRegistration.value;
|
||||
const registrationEnvironment =
|
||||
client.name === "kimi" || client.name === "codex"
|
||||
compactInputSchemaByDefault(client) || client.name === "codex"
|
||||
? await clientRegistrationEnvironmentDetailsWithBudget(
|
||||
parsed,
|
||||
{},
|
||||
"effective",
|
||||
options.resolveProviderRequestBudget,
|
||||
client.name === "kimi" || client.name === "codex"
|
||||
? options.resolveProviderRequestBudget
|
||||
: undefined,
|
||||
options.platform,
|
||||
)
|
||||
: undefined;
|
||||
@@ -321,7 +324,7 @@ export const readClientRegistrationInspections = async (
|
||||
command,
|
||||
commandFacts,
|
||||
platform: options.platform ?? process.platform,
|
||||
kimiInputSchemaProfileSelected:
|
||||
inputSchemaProfileSelected:
|
||||
registrationEnvironment?.ok === true &&
|
||||
snapshotEnvironment(
|
||||
registrationEnvironment.value.values,
|
||||
@@ -449,14 +452,14 @@ const registrationAligned = ({
|
||||
command,
|
||||
commandFacts,
|
||||
platform,
|
||||
kimiInputSchemaProfileSelected,
|
||||
inputSchemaProfileSelected,
|
||||
}: {
|
||||
readonly registration: ClientRegistration;
|
||||
readonly client: SetupClient;
|
||||
readonly command: readonly string[];
|
||||
readonly commandFacts: ClientRegistrationCommandFacts;
|
||||
readonly platform: NodeJS.Platform;
|
||||
readonly kimiInputSchemaProfileSelected: boolean;
|
||||
readonly inputSchemaProfileSelected: boolean;
|
||||
}): "aligned" | "stale" | "unknown" => {
|
||||
const { identity } = commandFacts;
|
||||
if (identity === "unknown") return "unknown";
|
||||
@@ -478,7 +481,8 @@ const registrationAligned = ({
|
||||
registration.toolTimeoutMs === undefined)
|
||||
)
|
||||
return "stale";
|
||||
if (client.name === "kimi" && !kimiInputSchemaProfileSelected) return "stale";
|
||||
if (compactInputSchemaByDefault(client) && !inputSchemaProfileSelected)
|
||||
return "stale";
|
||||
// A Harness registration only connects as the rea stdio server; another
|
||||
// server name or transport leaves the session without REA tools.
|
||||
if (
|
||||
|
||||
@@ -67,6 +67,7 @@ import type {
|
||||
SetupProviderEnvironment,
|
||||
} from "./SetupTypes.js";
|
||||
import {
|
||||
compactInputSchemaByDefault,
|
||||
registrationWriteBlocker,
|
||||
type SetupClient,
|
||||
} from "./SupportedClients.js";
|
||||
@@ -936,7 +937,7 @@ const clientConfigurationDesired = async (
|
||||
);
|
||||
if (!environment.ok) throw environment.error;
|
||||
const registrationEnvironment =
|
||||
client.name === "kimi" &&
|
||||
compactInputSchemaByDefault(client) &&
|
||||
snapshotEnvironment(environment.value).REA_MCP_INPUT_SCHEMA_PROFILE ===
|
||||
undefined
|
||||
? {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { dirname, isAbsolute, join, resolve, win32 } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { lstatSync } from "node:fs";
|
||||
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
HermesClientPathError,
|
||||
@@ -59,6 +59,7 @@ export interface SetupClient {
|
||||
| "omp"
|
||||
| "pi"
|
||||
| "kimi"
|
||||
| "lm_studio"
|
||||
| "hermes"
|
||||
| "zcode_fallback"
|
||||
| "zcode"
|
||||
@@ -193,6 +194,40 @@ const codexDirectory = ({ home, env }: ClientPathContext): string =>
|
||||
const kimiDirectory = ({ home, env }: ClientPathContext): string =>
|
||||
env.KIMI_CODE_HOME || join(home, ".kimi-code");
|
||||
|
||||
/** Clients registered with the compact input-schema profile unless one is selected. */
|
||||
export const compactInputSchemaByDefault = ({
|
||||
name,
|
||||
}: Pick<SetupClient, "name">): boolean =>
|
||||
name === "kimi" || name === "lm_studio";
|
||||
|
||||
/**
|
||||
* LM Studio's findLMStudioHome, read-only: a home pointer file wins, then an
|
||||
* existing ~/.cache/lm-studio, then ~/.lmstudio. Setup never writes the pointer.
|
||||
*/
|
||||
const lmStudioLocation = ({
|
||||
home,
|
||||
}: ClientPathContext): Result<ClientLocation, ClientPathFailure> => {
|
||||
const pointerPath = join(home, ".lmstudio-home-pointer");
|
||||
let directory: string;
|
||||
try {
|
||||
directory = readFileSync(pointerPath, "utf8").trim();
|
||||
} catch (cause: unknown) {
|
||||
if (!(cause instanceof Error && "code" in cause && cause.code === "ENOENT"))
|
||||
return err({
|
||||
path: pointerPath,
|
||||
message: `${pointerPath} could not be read, so REA cannot establish which LM Studio home holds mcp.json: ${cause instanceof Error ? cause.message : String(cause)}. Repair access to that file, then rerun.`,
|
||||
});
|
||||
const cache = join(home, ".cache", "lm-studio");
|
||||
directory = existsSync(cache) ? cache : join(home, ".lmstudio");
|
||||
}
|
||||
if (!isAbsolute(directory))
|
||||
return err({
|
||||
path: pointerPath,
|
||||
message: `${pointerPath} does not hold an absolute LM Studio home path. Correct or remove that file, then rerun.`,
|
||||
});
|
||||
return ok({ configPath: join(directory, "mcp.json"), markerPath: directory });
|
||||
};
|
||||
|
||||
const qoderDirectory = ({ home, env }: ClientPathContext): string =>
|
||||
env.QODER_CONFIG_DIR || join(home, ".qoder");
|
||||
|
||||
@@ -652,6 +687,12 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
|
||||
markerPath: [".workbuddy"],
|
||||
format: "json",
|
||||
},
|
||||
{
|
||||
name: "lm_studio",
|
||||
displayName: "LM Studio",
|
||||
location: lmStudioLocation,
|
||||
format: "lm_studio",
|
||||
},
|
||||
{
|
||||
name: "grok_build",
|
||||
displayName: "Grok Build",
|
||||
|
||||
@@ -62,6 +62,7 @@ describe("client configuration filesystem lifecycle", () => {
|
||||
"vscode",
|
||||
"trae",
|
||||
"workbuddy",
|
||||
"lm_studio",
|
||||
"grok_build",
|
||||
"omp",
|
||||
"pi",
|
||||
|
||||
@@ -167,6 +167,8 @@ describe("client registration environment validation", () => {
|
||||
const home = await createTestTempDirectory("rea-registration-budget-");
|
||||
await mkdir(join(home, ".cursor"));
|
||||
await mkdir(join(home, ".codex"));
|
||||
await mkdir(join(home, ".lmstudio"));
|
||||
await mkdir(join(home, ".kimi-code"));
|
||||
const entry = {
|
||||
command: "npx",
|
||||
args: ["-y", PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"],
|
||||
@@ -175,6 +177,11 @@ describe("client registration environment validation", () => {
|
||||
join(home, ".cursor/mcp.json"),
|
||||
JSON.stringify({ mcpServers: { rea: entry } }),
|
||||
);
|
||||
for (const path of [".lmstudio/mcp.json", ".kimi-code/mcp.json"])
|
||||
await writeFile(
|
||||
join(home, path),
|
||||
JSON.stringify({ mcpServers: { rea: entry } }),
|
||||
);
|
||||
await writeFile(
|
||||
join(home, ".codex/config.toml"),
|
||||
`[mcp_servers.rea]\ncommand = "npx"\nargs = ["-y", "${PRODUCT_IDENTITY.registrationPackageSpecifier}", "mcp"]\n`,
|
||||
@@ -194,10 +201,13 @@ describe("client registration environment validation", () => {
|
||||
// The failing resolver must not invalidate non-Kimi/Codex registrations.
|
||||
expect(byClient.has("cursor")).toBe(true);
|
||||
expect(byClient.get("cursor")?.state).not.toBe("invalid");
|
||||
expect(byClient.get("codex")).toMatchObject({
|
||||
state: "invalid",
|
||||
remediation: expect.stringContaining("budget unavailable"),
|
||||
});
|
||||
expect(byClient.has("lm_studio")).toBe(true);
|
||||
expect(byClient.get("lm_studio")?.state).not.toBe("invalid");
|
||||
for (const client of ["codex", "kimi"])
|
||||
expect(byClient.get(client)).toMatchObject({
|
||||
state: "invalid",
|
||||
remediation: expect.stringContaining("budget unavailable"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js";
|
||||
import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js";
|
||||
import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js";
|
||||
import { supportedClients } from "../../../src/application/SupportedClients.js";
|
||||
import { systemUninstallHost } from "../../../src/application/Uninstall.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js";
|
||||
|
||||
const command = currentClientRegistrationCommand();
|
||||
|
||||
beforeEach(clearClientLocationEnvironment);
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
const lmStudioClient = (home: string) =>
|
||||
supportedClients(home, process.platform, {}).find(
|
||||
({ name }) => name === "lm_studio",
|
||||
);
|
||||
|
||||
const exists = async (path: string): Promise<boolean> => {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const expectedRegistration = (profile = "compact") => ({
|
||||
command: command[0],
|
||||
args: command.slice(1),
|
||||
env: { REA_MCP_INPUT_SCHEMA_PROFILE: profile },
|
||||
});
|
||||
|
||||
const doctorState = async (home: string) =>
|
||||
(
|
||||
await readClientRegistrationStatuses(home, undefined, { environment: {} })
|
||||
).find(({ client }) => client === "lm_studio");
|
||||
|
||||
/** An installed LM Studio home; the pointer and cache layouts are opt-in. */
|
||||
const installedHome = async (layout: "default" | "pointer" | "cache") => {
|
||||
const home = await createTestTempDirectory("rea-lm-studio-");
|
||||
let directory = join(home, ".lmstudio");
|
||||
if (layout === "pointer") {
|
||||
directory = await createTestTempDirectory("rea-lm-studio-target-");
|
||||
await writeFile(join(home, ".lmstudio-home-pointer"), `${directory}\n`);
|
||||
}
|
||||
if (layout === "cache") directory = join(home, ".cache", "lm-studio");
|
||||
await mkdir(directory, { recursive: true });
|
||||
const client = lmStudioClient(home);
|
||||
if (client === undefined) throw new Error("missing LM Studio client");
|
||||
return { home, directory, client };
|
||||
};
|
||||
|
||||
describe("LM Studio home resolution", () => {
|
||||
it.each(["default", "pointer", "cache"] as const)(
|
||||
"resolves mcp.json in the %s home",
|
||||
async (layout) => {
|
||||
const { directory, client } = await installedHome(layout);
|
||||
expect(client).toEqual({
|
||||
name: "lm_studio",
|
||||
displayName: "LM Studio",
|
||||
configPath: join(directory, "mcp.json"),
|
||||
markerPath: directory,
|
||||
format: "lm_studio",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("prefers the home pointer over an existing cache directory", async () => {
|
||||
const { home, directory } = await installedHome("pointer");
|
||||
await mkdir(join(home, ".cache", "lm-studio"), { recursive: true });
|
||||
expect(lmStudioClient(home)?.markerPath).toBe(directory);
|
||||
});
|
||||
|
||||
it("reports a relative home pointer instead of guessing a home", async () => {
|
||||
const home = await createTestTempDirectory("rea-lm-studio-relative-");
|
||||
await writeFile(join(home, ".lmstudio-home-pointer"), "lmstudio\n");
|
||||
expect(lmStudioClient(home)).toMatchObject({
|
||||
configPath: join(home, ".lmstudio-home-pointer"),
|
||||
configPathError: expect.stringContaining("absolute"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("LM Studio registration lifecycle", () => {
|
||||
it.each(["default", "pointer", "cache"] as const)(
|
||||
"registers rea with the compact profile in the %s home",
|
||||
async (layout) => {
|
||||
const { home, directory, client } = await installedHome(layout);
|
||||
const pointer = join(home, ".lmstudio-home-pointer");
|
||||
const pointerBefore = (await exists(pointer))
|
||||
? await readFile(pointer, "utf8")
|
||||
: undefined;
|
||||
|
||||
expect(
|
||||
(await configureClientConfiguration(client, {}, command)).status,
|
||||
).toBe("configured");
|
||||
expect(
|
||||
JSON.parse(await readFile(join(directory, "mcp.json"), "utf8")),
|
||||
).toEqual({ mcpServers: { rea: expectedRegistration() } });
|
||||
expect(await doctorState(home)).toMatchObject({
|
||||
state: "aligned",
|
||||
command,
|
||||
});
|
||||
expect(
|
||||
(await exists(pointer)) ? await readFile(pointer, "utf8") : undefined,
|
||||
).toBe(pointerBefore);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves sibling servers, backs up, is idempotent, and uninstalls only rea", async () => {
|
||||
const { home, client } = await installedHome("default");
|
||||
const original = `{
|
||||
"mcpServers": {
|
||||
"other": { "command": "other-server", "args": ["--stdio"], "cwd": "/srv" }
|
||||
}
|
||||
}
|
||||
`;
|
||||
await writeFile(client.configPath, original);
|
||||
|
||||
expect(await configureClientConfiguration(client, {}, command)).toEqual({
|
||||
status: "configured",
|
||||
backupPath: `${client.configPath}.rea.backup`,
|
||||
});
|
||||
const text = await readFile(client.configPath, "utf8");
|
||||
expect(JSON.parse(text)).toEqual({
|
||||
mcpServers: {
|
||||
other: { command: "other-server", args: ["--stdio"], cwd: "/srv" },
|
||||
rea: expectedRegistration(),
|
||||
},
|
||||
});
|
||||
expect(text).not.toContain('"type"');
|
||||
expect(text).not.toContain('"timeout"');
|
||||
expect(await readFile(`${client.configPath}.rea.backup`, "utf8")).toBe(
|
||||
original,
|
||||
);
|
||||
expect(await configureClientConfiguration(client, {}, command)).toEqual({
|
||||
status: "unchanged",
|
||||
});
|
||||
expect(await readFile(client.configPath, "utf8")).toBe(text);
|
||||
expect(await doctorState(home)).toMatchObject({ state: "aligned" });
|
||||
|
||||
expect((await systemUninstallHost(home).removeClient(client)).status).toBe(
|
||||
"removed",
|
||||
);
|
||||
expect(JSON.parse(await readFile(client.configPath, "utf8"))).toEqual({
|
||||
mcpServers: {
|
||||
other: { command: "other-server", args: ["--stdio"], cwd: "/srv" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an explicit full profile and reports an omitted profile stale", async () => {
|
||||
const { home, client } = await installedHome("default");
|
||||
await writeFile(
|
||||
client.configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: { rea: { command: command[0], args: command.slice(1) } },
|
||||
}),
|
||||
);
|
||||
expect(await doctorState(home)).toMatchObject({ state: "stale" });
|
||||
|
||||
expect(
|
||||
(
|
||||
await configureClientConfiguration(
|
||||
client,
|
||||
{ REA_MCP_INPUT_SCHEMA_PROFILE: "full" },
|
||||
command,
|
||||
)
|
||||
).status,
|
||||
).toBe("configured");
|
||||
expect(
|
||||
JSON.parse(await readFile(client.configPath, "utf8")).mcpServers.rea,
|
||||
).toEqual(expectedRegistration("full"));
|
||||
expect(await configureClientConfiguration(client, {}, command)).toEqual({
|
||||
status: "unchanged",
|
||||
});
|
||||
expect(await doctorState(home)).toMatchObject({ state: "aligned" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
'{"mcpServers": {',
|
||||
'{/* comment */ "mcpServers": {}}',
|
||||
'{"mcpServers": {},}',
|
||||
'{"mcpServers": {}}',
|
||||
])("rejects invalid strict JSON %j without writes", async (original) => {
|
||||
const { client } = await installedHome("default");
|
||||
await writeFile(client.configPath, original);
|
||||
expect(await configureClientConfiguration(client, {}, command)).toEqual({
|
||||
status: "failed",
|
||||
reason: "readback",
|
||||
});
|
||||
expect(await readFile(client.configPath, "utf8")).toBe(original);
|
||||
expect(await exists(`${client.configPath}.rea.backup`)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user