mirror of
https://github.com/morluto/rea.git
synced 2026-10-28 18:35:53 +00:00
* refactor(setup): resolve skill destinations per client * fix(setup): honor active client configuration and skill roots * fix(setup): validate effective client scopes and Gemini trust * fix(setup): treat empty OpenCode overrides as unset An empty OPENCODE_CONFIG_DIR now stays on the XDG directory, and doctor and setup inspect global OpenCode files when a custom directory is absent. Uninstall reports one status per client so a missing earlier layer does not hide a removed registration. --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
337 lines
9.6 KiB
TypeScript
337 lines
9.6 KiB
TypeScript
import { readClientPolicyBlock } from "./GeminiClientSettings.js";
|
|
import {
|
|
effectiveClientConfiguration,
|
|
readClientConfigurationFiles,
|
|
} from "./ClientConfigurationFiles.js";
|
|
import {
|
|
clientServerForcedEnabled,
|
|
clientServerListedDisabled,
|
|
effectiveClientServer,
|
|
} from "./ClientConfigurationDocument.js";
|
|
import { access } from "node:fs/promises";
|
|
import { resolve } from "node:path";
|
|
|
|
import { z } from "zod";
|
|
|
|
import { PRODUCT_IDENTITY } from "../identity.js";
|
|
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
|
|
import {
|
|
isOwnedClientRegistrationCommand,
|
|
npxRegistrationCommand,
|
|
} from "./ClientRegistrationIdentity.js";
|
|
import {
|
|
clientEvidencePaths,
|
|
manualRegistrationRemediation,
|
|
supportedClients,
|
|
} from "./SupportedClients.js";
|
|
import type { SetupClient } from "./SupportedClients.js";
|
|
|
|
interface ClientRegistrationStatusBase {
|
|
readonly client: string;
|
|
readonly config_path: string;
|
|
}
|
|
|
|
type RegistrationCommand = readonly [string, ...string[]];
|
|
|
|
export type ClientRegistrationStatus = ClientRegistrationStatusBase &
|
|
(
|
|
| {
|
|
readonly command: RegistrationCommand;
|
|
readonly state: "aligned";
|
|
readonly remediation: null;
|
|
}
|
|
| {
|
|
readonly command: RegistrationCommand;
|
|
readonly state: "stale";
|
|
readonly remediation: string;
|
|
}
|
|
| {
|
|
readonly command: readonly [];
|
|
readonly state: "missing" | "invalid" | "manual";
|
|
readonly remediation: string;
|
|
}
|
|
);
|
|
|
|
export type UnhealthyClientRegistrationStatus = Exclude<
|
|
ClientRegistrationStatus,
|
|
{ readonly state: "aligned" }
|
|
>;
|
|
|
|
const registrationSchema = z
|
|
.object({
|
|
command: z.string().min(1),
|
|
args: z.array(z.string()).default([]),
|
|
startup_timeout_sec: z.number().positive().optional(),
|
|
type: z.string().optional(),
|
|
transport: z.string().optional(),
|
|
tools: z.array(z.string()).optional(),
|
|
disabled: z.boolean().optional(),
|
|
enabled: z.boolean().optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
/** Inspect supported client registrations without reading their environment. */
|
|
export const readClientRegistrationStatuses = async (
|
|
home: string,
|
|
currentCommandPath: string = resolve(process.argv[1] ?? "unknown"),
|
|
options: {
|
|
readonly platform?: NodeJS.Platform;
|
|
readonly environment?: NodeJS.ProcessEnv;
|
|
} = {},
|
|
): Promise<readonly ClientRegistrationStatus[]> => {
|
|
const statuses: ClientRegistrationStatus[] = [];
|
|
for (const client of supportedClients(
|
|
home,
|
|
options.platform,
|
|
options.environment,
|
|
)) {
|
|
if (client.configPathError !== undefined) {
|
|
statuses.push({
|
|
client: client.name,
|
|
config_path: client.configPath,
|
|
command: [],
|
|
state: "invalid",
|
|
remediation: client.configPathError,
|
|
});
|
|
continue;
|
|
}
|
|
if (!(await clientEvidencePresent(client))) continue;
|
|
const manualRemediation = manualRegistrationRemediation(client.name);
|
|
if (client.format === "unsupported") {
|
|
if (manualRemediation !== undefined)
|
|
statuses.push({
|
|
client: client.name,
|
|
config_path: client.markerPath ?? client.configPath,
|
|
command: [],
|
|
state: "manual",
|
|
remediation: manualRemediation,
|
|
});
|
|
continue;
|
|
}
|
|
try {
|
|
const files = await readClientConfigurationFiles(client);
|
|
if (!files.ok) {
|
|
statuses.push({
|
|
client: client.name,
|
|
config_path: client.configPath,
|
|
command: [],
|
|
state: "invalid",
|
|
remediation: files.error.detail,
|
|
});
|
|
continue;
|
|
}
|
|
const parsed = effectiveClientConfiguration(files.value);
|
|
if (parsed === undefined) {
|
|
statuses.push(
|
|
unavailableStatus(client.name, client.configPath, "missing"),
|
|
);
|
|
continue;
|
|
}
|
|
const policy = await readClientPolicyBlock(client, parsed);
|
|
if (!policy.ok) {
|
|
statuses.push({
|
|
client: client.name,
|
|
config_path: client.configPath,
|
|
command: [],
|
|
state: "invalid",
|
|
remediation: policy.error.detail,
|
|
});
|
|
continue;
|
|
}
|
|
const policyBlock = policy.value;
|
|
const raw = effectiveClientServer(parsed, PRODUCT_IDENTITY.mcpServerKey);
|
|
if (raw === undefined) {
|
|
statuses.push(
|
|
unavailableStatus(client.name, client.configPath, "missing"),
|
|
);
|
|
continue;
|
|
}
|
|
const registration = parseRegistration(raw, client);
|
|
const command: RegistrationCommand = [
|
|
registration.command,
|
|
...registration.args,
|
|
];
|
|
statuses.push(
|
|
configuredStatus(
|
|
client.name,
|
|
client.configPath,
|
|
command,
|
|
registrationAligned(
|
|
registration,
|
|
client,
|
|
currentCommandPath,
|
|
options.platform ?? process.platform,
|
|
clientServerForcedEnabled(parsed, PRODUCT_IDENTITY.mcpServerKey),
|
|
) &&
|
|
!clientServerListedDisabled(
|
|
parsed,
|
|
PRODUCT_IDENTITY.mcpServerKey,
|
|
) &&
|
|
policyBlock === undefined
|
|
? "aligned"
|
|
: "stale",
|
|
policyBlock,
|
|
),
|
|
);
|
|
} catch (cause: unknown) {
|
|
statuses.push(
|
|
unavailableStatus(
|
|
client.name,
|
|
client.configPath,
|
|
isMissing(cause) ? "missing" : "invalid",
|
|
),
|
|
);
|
|
}
|
|
}
|
|
return statuses.sort((left, right) =>
|
|
left.client.localeCompare(right.client),
|
|
);
|
|
};
|
|
|
|
const registrationAligned = (
|
|
registration: z.output<typeof registrationSchema>,
|
|
client: SetupClient,
|
|
currentCommandPath: string,
|
|
platform: NodeJS.Platform,
|
|
forcedEnabled = false,
|
|
): boolean => {
|
|
const command = [registration.command, ...registration.args];
|
|
if (
|
|
(client.format !== "grok" && registration.disabled === true) ||
|
|
(registration.enabled === false && !forcedEnabled)
|
|
)
|
|
return false;
|
|
if (!isOwnedClientRegistrationCommand(command, currentCommandPath))
|
|
return false;
|
|
if (
|
|
(client.name === "codex" || client.name === "grok_build") &&
|
|
registration.startup_timeout_sec !==
|
|
MCP_STARTUP_POLICY.codexStartupTimeoutSeconds
|
|
)
|
|
return false;
|
|
if (client.format === "vscode" && registration.type !== "stdio") return false;
|
|
// OMP and Pi infer stdio for a command entry without an explicit type.
|
|
if (
|
|
(client.format === "omp" || client.format === "pi") &&
|
|
registration.type !== undefined &&
|
|
registration.type !== "stdio"
|
|
)
|
|
return false;
|
|
if (
|
|
client.format === "copilot_cli" &&
|
|
(registration.type !== "stdio" ||
|
|
JSON.stringify(registration.tools) !== JSON.stringify(["*"]))
|
|
)
|
|
return false;
|
|
if (client.format === "commandcode" && registration.transport !== "stdio")
|
|
return false;
|
|
if (
|
|
command.length === 3 &&
|
|
command[2] === "mcp" &&
|
|
resolve(command[0] ?? "") === resolve(process.execPath) &&
|
|
resolve(command[1] ?? "") === currentCommandPath
|
|
)
|
|
return true;
|
|
const npxCommand = npxRegistrationCommand(platform);
|
|
if (
|
|
command.length === npxCommand.length &&
|
|
command.every((argument, index) => argument === npxCommand[index])
|
|
)
|
|
return true;
|
|
return (
|
|
command.length === 2 &&
|
|
command[1] === "mcp" &&
|
|
resolve(command[0] ?? "") === currentCommandPath
|
|
);
|
|
};
|
|
|
|
const parseRegistration = (
|
|
value: unknown,
|
|
client: SetupClient,
|
|
): z.output<typeof registrationSchema> => {
|
|
if (client.format === "opencode") {
|
|
const entry = z
|
|
.object({
|
|
type: z.literal("local"),
|
|
command: z.array(z.string()).min(1),
|
|
environment: z.record(z.string(), z.string()).optional(),
|
|
})
|
|
.passthrough()
|
|
.parse(value);
|
|
const [command = "", ...args] = entry.command;
|
|
return registrationSchema.parse({
|
|
...entry,
|
|
command,
|
|
args,
|
|
});
|
|
}
|
|
const registration = registrationSchema.parse(value);
|
|
if (
|
|
(client.format === "vscode" || client.format === "copilot_cli") &&
|
|
registration.type !== "stdio"
|
|
)
|
|
throw new TypeError("Expected an stdio registration");
|
|
if (client.format === "commandcode" && registration.transport !== "stdio")
|
|
throw new TypeError("Expected an stdio registration");
|
|
if (
|
|
(client.format === "omp" || client.format === "pi") &&
|
|
registration.type !== undefined &&
|
|
registration.type !== "stdio"
|
|
)
|
|
throw new TypeError("Expected an stdio registration");
|
|
return registration;
|
|
};
|
|
|
|
const remediation =
|
|
"Run rea setup to refresh this registration, then restart the client.";
|
|
|
|
const configuredStatus = (
|
|
client: string,
|
|
configPath: string,
|
|
command: RegistrationCommand,
|
|
state: "aligned" | "stale",
|
|
policyRemediation?: string,
|
|
): ClientRegistrationStatus =>
|
|
state === "aligned"
|
|
? { client, config_path: configPath, command, state, remediation: null }
|
|
: {
|
|
client,
|
|
config_path: configPath,
|
|
command,
|
|
state,
|
|
remediation: policyRemediation ?? remediation,
|
|
};
|
|
|
|
const unavailableStatus = (
|
|
client: string,
|
|
configPath: string,
|
|
state: "missing" | "invalid",
|
|
): ClientRegistrationStatus => ({
|
|
client,
|
|
config_path: configPath,
|
|
command: [],
|
|
state,
|
|
remediation,
|
|
});
|
|
|
|
const clientEvidencePresent = async (client: SetupClient): Promise<boolean> => {
|
|
for (const path of clientEvidencePaths(client))
|
|
if (await exists(path)) return true;
|
|
return false;
|
|
};
|
|
|
|
const exists = async (path: string | undefined): Promise<boolean> => {
|
|
if (path === undefined) return false;
|
|
try {
|
|
await access(path);
|
|
return true;
|
|
} catch (cause: unknown) {
|
|
// best-effort cleanup: optional registration probing; absence means unregistered.
|
|
void cause;
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const isMissing = (cause: unknown): boolean =>
|
|
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
|