fix(setup): honor active client configuration and skill roots (#1504)

* 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 <[email protected]>
This commit is contained in:
morluto
2026-10-10 17:03:43 +08:00
committed by GitHub
co-authored by Cursor Agent
parent b25922e4e8
commit 7a56fe6260
14 changed files with 1562 additions and 61 deletions
+30 -7
View File
@@ -175,9 +175,26 @@ Setup and doctor follow Qwen Code's tilde and working-directory-relative home
overrides. Qwen Code also discovers personal skills in `~/.agents/skills`,
where setup installs the bundled workflow.
Gemini CLI uses `$GEMINI_CLI_HOME/.gemini/settings.json` when that home override
is nonempty, including its shared personal skills at
`$GEMINI_CLI_HOME/.agents/skills`. Setup and doctor check MCP allow/exclude
policies from system defaults, user settings, trusted workspace settings, and
system overrides. Workspace trust follows `GEMINI_CLI_TRUST_WORKSPACE`,
`GEMINI_RESTRICTED_MODE`, the effective `security.folderTrust.enabled` setting,
and the selected `trustedFolders.json` (including
`GEMINI_CLI_TRUSTED_FOLDERS_PATH`). Settings in an untrusted workspace do not
participate. A workspace or system `mcpServers.rea` replaces the entire user
entry; setup stops if the winning definition conflicts with its planned entry.
Review those files manually: setup never edits administrator/workspace settings
or grants workspace trust. Trust supplied only by a running IDE is outside this
standalone CLI check.
Codex treats an unset or empty `CODEX_HOME` as `~/.codex`. Devin honors
`XDG_CONFIG_HOME` for its `devin/mcp_config.json` on non-Windows systems.
For OMP, setup writes a `type: "stdio"` entry to the user-level
`~/.omp/agent/mcp.json`. It follows `PI_CONFIG_DIR`, an absolute
`PI_CODING_AGENT_DIR`, and the profile selected by `OMP_PROFILE` or
`~/.omp/agent/mcp.json`. It follows `PI_CONFIG_DIR` and
`PI_CODING_AGENT_DIR` (relative to the setup working directory), and the profile selected by `OMP_PROFILE` or
`PI_PROFILE` (`~/.omp/profiles/<name>/agent/mcp.json`). Setup also removes
`rea` from that file's `disabledServers` list, which would otherwise hide the
registration. Doctor treats an `enabled: false` entry as active when
@@ -198,10 +215,15 @@ configuration and skill destinations; doctor and uninstall use those same paths.
For OpenCode, setup writes the V1 `mcp.rea` entry, which OpenCode V1 and V2
both load. If the configuration already uses OpenCode V2's native
`mcp.servers` table, setup registers REA there instead and replaces any earlier
`mcp.rea` entry from REA.
`mcp.rea` entry from REA. Setup honors `OPENCODE_CONFIG_DIR` and
`OPENCODE_CONFIG`, preferring the later-loaded JSONC file when both JSON and
JSONC exist. The ordinary XDG global configuration still participates when a
custom directory is selected. Doctor checks the merged user registration; uninstall removes
REA-owned entries from every applicable user file so an older entry cannot
become active again. Malformed applicable files block setup before changes.
Grok Build loads `[mcp_servers.rea]` from `$GROK_HOME/config.toml`, or from
`~/.grok/config.toml` when `GROK_HOME` is unset. Setup edits that server
`~/.grok/config.toml` when `GROK_HOME` is unset or empty. Setup edits that server
table, `[mcp_servers.rea.env]`, and a root `disabled_mcp_servers` entry that
names `rea`. It sets `startup_timeout_sec = 30` and leaves every other name
in that list. The shared skill installed under `~/.agents/skills` is already
@@ -241,9 +263,10 @@ save verified paths for an existing Ghidra installation.
The bundled skill is installed where each selected client discovers personal
skills: Claude Code uses `~/.claude/skills` (or
`$CLAUDE_CONFIG_DIR/skills` when configured), while other supported clients,
including Qwen Code, use the shared `~/.agents/skills` directory. A mixed selection
plans the relevant paths. Selecting the skill without a client uses the shared
`$CLAUDE_CONFIG_DIR/skills` when configured). Gemini CLI uses `.agents/skills`
under its selected home; Antigravity 2.0 uses `~/.gemini/config/skills`.
Other integrations, including Qwen Code, use `~/.agents/skills`.
A mixed selection plans each distinct destination. Selecting the skill without a client uses the shared
directory. Setup leaves existing skill copies in other locations untouched.
`doctor --skill --json` verifies the selected copies against the bundled
+4
View File
@@ -87,6 +87,10 @@ export async function verifyPackageEnvironment({
PI_PROFILE: undefined,
QWEN_HOME: undefined,
OPENCODE_CONFIG: undefined,
OPENCODE_CONFIG_DIR: undefined,
GEMINI_CLI_HOME: undefined,
GEMINI_CLI_SYSTEM_SETTINGS_PATH: undefined,
GEMINI_CLI_SYSTEM_DEFAULTS_PATH: undefined,
XDG_CONFIG_HOME: join(home, ".config"),
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
REA_ANALYSIS_PROVIDER: "auto",
@@ -1413,3 +1413,45 @@ export const clientRegistrationEntry = (
};
}
};
/** Parse one Gemini policy at its source boundary before consolidating scopes. */
export const parseGeminiMcpPolicy = (parsed: ClientConfigurationDocument) =>
z
.object({
allowed: z.array(z.string()).optional(),
excluded: z.array(z.string()).optional(),
})
.passthrough()
.parse(parsed.document.mcp ?? {});
/** Gemini consolidates excludes by union and allowlists by normalized intersection. */
export const geminiServerPolicyBlock = (
policies: readonly ReturnType<typeof parseGeminiMcpPolicy>[],
serverKey: string,
): string | undefined => {
if (policies.some(({ excluded }) => excluded?.includes(serverKey)))
return `Gemini mcp.excluded blocks ${serverKey}. Review the selected settings policies before rerunning setup.`;
const allowlists = policies.flatMap(({ allowed }) =>
allowed === undefined ? [] : [allowed],
);
const allowed = allowlists.reduce<string[] | undefined>(
(previous, current) => {
if (previous === undefined) return current;
const normalized = new Set(
current.map((name) => name.toLowerCase().trim()),
);
return previous.filter((name) =>
normalized.has(name.toLowerCase().trim()),
);
},
undefined,
);
// Gemini's manager applies the consolidated allowlist only when nonempty.
if (
allowed !== undefined &&
allowed.length > 0 &&
!allowed.includes(serverKey)
)
return `Gemini mcp.allowed does not include ${serverKey}. Review the selected settings policies before rerunning setup.`;
return undefined;
};
+108
View File
@@ -0,0 +1,108 @@
import { err, ok, type Result } from "../domain/result.js";
import { readFile } from "node:fs/promises";
import {
effectiveClientServer,
parseClientConfiguration,
type ClientConfigurationDocument,
} from "./ClientConfigurationDocument.js";
import type { SetupClient } from "./SupportedClients.js";
/** A named source failure that setup can explain without losing the bad path. */
export interface ClientConfigurationFileError {
readonly kind: "unreadable" | "malformed";
readonly path: string;
readonly detail: string;
}
/** Preserve named read failures and distinguish a missing source from an empty file. */
export const readConfigurationText = async (
path: string,
): Promise<Result<string | undefined, ClientConfigurationFileError>> => {
try {
return ok(await readFile(path, "utf8"));
} catch (cause: unknown) {
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
return ok(undefined);
return err({
kind: "unreadable",
path,
detail: `Configuration ${path} could not be read: ${cause instanceof Error ? cause.message : String(cause)}`,
});
}
};
/** Read and parse one configuration at its source boundary. */
export const readConfigurationFile = async (
path: string,
format: SetupClient["format"],
): Promise<
Result<ClientConfigurationDocument | undefined, ClientConfigurationFileError>
> => {
const source = await readConfigurationText(path);
if (!source.ok) return source;
const content = source.value;
if (content === undefined) return ok(undefined);
try {
return ok(parseClientConfiguration(content, format));
} catch (cause: unknown) {
return err({
kind: "malformed",
path,
detail: `Configuration ${path} is malformed: ${cause instanceof Error ? cause.message : String(cause)}`,
});
}
};
/** Read every applicable user file; malformed lower-priority files also stop setup. */
export const readClientConfigurationFiles = async (
client: SetupClient,
): Promise<
Result<readonly ClientConfigurationDocument[], ClientConfigurationFileError>
> => {
const documents: ClientConfigurationDocument[] = [];
for (const path of client.configPaths ?? [client.configPath]) {
const document = await readConfigurationFile(path, client.format);
if (!document.ok) return document;
if (document.value !== undefined) documents.push(document.value);
}
return ok(documents);
};
/** OpenCode merges server objects in file order, replacing arrays and scalar values. */
const mergeConfigurationValue = (previous: unknown, next: unknown): unknown => {
if (!isObject(previous) || !isObject(next)) return next;
return Object.fromEntries(
[...new Set([...Object.keys(previous), ...Object.keys(next)])].map(
(key) => [
key,
Object.hasOwn(next, key)
? mergeConfigurationValue(previous[key], next[key])
: previous[key],
],
),
);
};
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
/** Project a client's effective user registration without modifying its source files. */
export const effectiveClientConfiguration = (
documents: readonly ClientConfigurationDocument[],
): ClientConfigurationDocument | undefined => {
const last = documents.at(-1);
if (last === undefined) return undefined;
if (documents.length === 1) return last;
const servers: Record<string, unknown> = {};
for (const document of documents) {
const names = new Set([
...Object.keys(document.legacyServers),
...Object.keys(document.servers),
]);
for (const name of names)
servers[name] = mergeConfigurationValue(
servers[name],
effectiveClientServer(document, name),
);
}
return { ...last, servers, legacyServers: {} };
};
+59 -12
View File
@@ -1,10 +1,14 @@
import { readClientPolicyBlock } from "./GeminiClientSettings.js";
import {
effectiveClientConfiguration,
readClientConfigurationFiles,
} from "./ClientConfigurationFiles.js";
import {
clientServerForcedEnabled,
clientServerListedDisabled,
effectiveClientServer,
parseClientConfiguration,
} from "./ClientConfigurationDocument.js";
import { access, readFile } from "node:fs/promises";
import { access } from "node:fs/promises";
import { resolve } from "node:path";
import { z } from "zod";
@@ -16,6 +20,7 @@ import {
npxRegistrationCommand,
} from "./ClientRegistrationIdentity.js";
import {
clientEvidencePaths,
manualRegistrationRemediation,
supportedClients,
} from "./SupportedClients.js";
@@ -90,11 +95,7 @@ export const readClientRegistrationStatuses = async (
});
continue;
}
if (
!(await exists(client.markerPath)) &&
!(await exists(client.configPath))
)
continue;
if (!(await clientEvidencePresent(client))) continue;
const manualRemediation = manualRegistrationRemediation(client.name);
if (client.format === "unsupported") {
if (manualRemediation !== undefined)
@@ -108,8 +109,36 @@ export const readClientRegistrationStatuses = async (
continue;
}
try {
const content = await readFile(client.configPath, "utf8");
const parsed = parseClientConfiguration(content, client.format);
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(
@@ -134,9 +163,14 @@ export const readClientRegistrationStatuses = async (
options.platform ?? process.platform,
clientServerForcedEnabled(parsed, PRODUCT_IDENTITY.mcpServerKey),
) &&
!clientServerListedDisabled(parsed, PRODUCT_IDENTITY.mcpServerKey)
!clientServerListedDisabled(
parsed,
PRODUCT_IDENTITY.mcpServerKey,
) &&
policyBlock === undefined
? "aligned"
: "stale",
policyBlock,
),
);
} catch (cause: unknown) {
@@ -163,7 +197,7 @@ const registrationAligned = (
): boolean => {
const command = [registration.command, ...registration.args];
if (
registration.disabled === true ||
(client.format !== "grok" && registration.disabled === true) ||
(registration.enabled === false && !forcedEnabled)
)
return false;
@@ -256,10 +290,17 @@ const configuredStatus = (
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 };
: {
client,
config_path: configPath,
command,
state,
remediation: policyRemediation ?? remediation,
};
const unavailableStatus = (
client: string,
@@ -273,6 +314,12 @@ const unavailableStatus = (
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 {
+202
View File
@@ -0,0 +1,202 @@
import { realpath } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { stripComments } from "jsonc-parser";
import { z } from "zod";
import { err, ok, type Result } from "../domain/result.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js";
import {
clientConfigurationValuesEqual,
clientRegistrationEntry,
geminiServerPolicyBlock,
parseGeminiMcpPolicy,
type ClientConfigurationDocument,
} from "./ClientConfigurationDocument.js";
import {
readConfigurationFile,
readConfigurationText,
type ClientConfigurationFileError,
} from "./ClientConfigurationFiles.js";
import type { SetupClient } from "./SupportedClients.js";
type SettingsSource = { path: string; document: ClientConfigurationDocument };
const folderTrustSchema = z
.object({
security: z
.object({
folderTrust: z
.object({ enabled: z.boolean().optional() })
.passthrough()
.optional(),
})
.passthrough()
.optional(),
})
.passthrough();
const trustRulesSchema = z.record(
z.string(),
z.enum(["TRUST_FOLDER", "TRUST_PARENT", "DO_NOT_TRUST"]),
);
const sourceFailure = (
path: string,
cause: unknown,
): ClientConfigurationFileError => ({
kind: "malformed",
path,
detail: `Gemini settings in ${path} are malformed: ${cause instanceof Error ? cause.message : String(cause)}`,
});
// Gemini resolves symlinks when possible and compares macOS/Windows paths without case.
const normalizedPath = async (
path: string,
platform: NodeJS.Platform,
): Promise<string> => {
const physical = await realpath(path).catch(() => path);
const absolute = resolve(physical).replaceAll("\\", "/");
return platform === "win32" || platform === "darwin"
? absolute.toLowerCase()
: absolute;
};
const workspaceTrusted = async (
settings: NonNullable<SetupClient["geminiSettings"]>,
sources: readonly SettingsSource[],
): Promise<Result<boolean, ClientConfigurationFileError>> => {
let enabled = true;
for (const { path, document } of sources) {
const parsed = folderTrustSchema.safeParse(document.document);
if (!parsed.success) return err(sourceFailure(path, parsed.error));
enabled = parsed.data.security?.folderTrust?.enabled ?? enabled;
}
if (settings.trustOverride !== undefined) return ok(settings.trustOverride);
if (!enabled) return ok(true);
const text = await readConfigurationText(settings.trustedFoldersPath);
if (!text.ok) return text;
if (text.value === undefined) return ok(false);
let rules: z.output<typeof trustRulesSchema>;
try {
// Gemini strips comments, but rejects trailing commas, BOMs and invalid trust levels.
rules = trustRulesSchema.parse(JSON.parse(stripComments(text.value)));
} catch (cause: unknown) {
return err(sourceFailure(settings.trustedFoldersPath, cause));
}
const workspace = await normalizedPath(
settings.workspaceDirectory,
settings.platform,
);
let longest = -1;
let trusted = false;
for (const [path, level] of Object.entries(rules)) {
const effective = await normalizedPath(
level === "TRUST_PARENT" ? dirname(path) : path,
settings.platform,
);
if (
(workspace === effective ||
workspace.startsWith(
effective.endsWith("/") ? effective : `${effective}/`,
)) &&
path.length > longest
) {
longest = path.length;
trusted = level !== "DO_NOT_TRUST";
}
}
return ok(trusted);
};
/** Check effective Gemini scopes while preserving their authority and source identity. */
export const readClientPolicyBlock = async (
client: SetupClient,
userDocument?: ClientConfigurationDocument,
desiredRegistration?: unknown,
): Promise<Result<string | undefined, ClientConfigurationFileError>> => {
if (client.name !== "gemini_cli" || client.geminiSettings === undefined)
return ok(undefined);
const settings = client.geminiSettings;
const sources: SettingsSource[] = [];
const higher: SettingsSource[] = [];
for (const path of [
settings.defaultsPath,
client.configPath,
settings.systemPath,
]) {
const parsed =
path === client.configPath && userDocument !== undefined
? ok(userDocument)
: await readConfigurationFile(path, "json");
if (!parsed.ok) return parsed;
if (parsed.value !== undefined)
sources.push({ path, document: parsed.value });
}
const workspace = await normalizedPath(
settings.workspaceDirectory,
settings.platform,
);
const home = await normalizedPath(
dirname(dirname(client.configPath)),
settings.platform,
);
if (workspace !== home) {
const path = resolve(
settings.workspaceDirectory,
".gemini",
"settings.json",
);
const document = await readConfigurationFile(path, "json");
if (!document.ok) return document;
if (document.value !== undefined) {
const trusted = await workspaceTrusted(settings, sources);
if (!trusted.ok) return trusted;
if (trusted.value) {
const source = { path, document: document.value };
const systemIndex = sources.findIndex(
(item) => item.path === settings.systemPath,
);
sources.splice(
systemIndex < 0 ? sources.length : systemIndex,
0,
source,
);
higher.push(source);
}
}
}
const system = sources.find(({ path }) => path === settings.systemPath);
if (system !== undefined) higher.push(system);
const policies: ReturnType<typeof parseGeminiMcpPolicy>[] = [];
for (const { path, document } of sources) {
try {
policies.push(parseGeminiMcpPolicy(document));
} catch (cause: unknown) {
return err(sourceFailure(path, cause));
}
}
const policy = geminiServerPolicyBlock(
policies,
PRODUCT_IDENTITY.mcpServerKey,
);
if (policy !== undefined)
return ok(
`${policy} Settings sources: ${sources.map(({ path }) => path).join(", ")}.`,
);
// mcpServers uses shallow merge: the entire same-name definition is replaced.
const override = higher.findLast(({ document }) =>
Object.hasOwn(document.servers, PRODUCT_IDENTITY.mcpServerKey),
);
const desired =
desiredRegistration ??
userDocument?.servers[PRODUCT_IDENTITY.mcpServerKey] ??
clientRegistrationEntry("json", npxRegistrationCommand(), {});
if (
override !== undefined &&
!clientConfigurationValuesEqual(
override.document.servers[PRODUCT_IDENTITY.mcpServerKey],
desired,
)
)
return ok(
`Gemini mcpServers.${PRODUCT_IDENTITY.mcpServerKey} in ${override.path} overrides the user registration. Review that settings file before rerunning setup; REA does not modify system or workspace settings.`,
);
return ok(undefined);
};
+65 -1
View File
@@ -1,3 +1,5 @@
import { readClientPolicyBlock } from "./GeminiClientSettings.js";
import { readClientConfigurationFiles } from "./ClientConfigurationFiles.js";
import {
clientRegistrationEntry,
clientConfigurationValuesEqual,
@@ -46,6 +48,15 @@ const configureClientDocument = async (
command: readonly string[],
format: NonNullable<SetupClient["format"]>,
): Promise<ClientConfigurationResult> => {
const files = await readClientConfigurationFiles(client);
if (!files.ok) return { status: "failed", reason: "readback" };
const policy = await readClientPolicyBlock(
client,
undefined,
clientConfigurationDesired(client, environment, command, client.format),
);
if (!policy.ok || policy.value !== undefined)
return { status: "failed", reason: "readback" };
const transactionPath = await resolveClientConfigTransactionPath(
client.configPath,
);
@@ -63,6 +74,13 @@ const configureClientDocument = async (
original ?? (format === "toml" || format === "grok" ? "" : "{}"),
format,
);
const policy = await readClientPolicyBlock(
client,
parsed,
clientConfigurationDesired(client, environment, command, parsed.dialect),
);
if (!policy.ok || policy.value !== undefined)
return { status: "failed", reason: "readback" };
} catch (cause: unknown) {
// Malformed existing configuration fails the readback gate.
void cause;
@@ -158,9 +176,12 @@ export const clientConfigurationAligned = async (
): Promise<boolean> => {
if (client.configPathError !== undefined) return false;
try {
const files = await readClientConfigurationFiles(client);
if (!files.ok) return false;
const original = await readFile(client.configPath, "utf8");
const parsed = parseClientConfiguration(original, client.format);
return registrationCurrent(
const policy = await readClientPolicyBlock(
client,
parsed,
clientConfigurationDesired(
client,
@@ -169,6 +190,19 @@ export const clientConfigurationAligned = async (
parsed.dialect,
),
);
return (
policy.ok &&
policy.value === undefined &&
registrationCurrent(
parsed,
clientConfigurationDesired(
client,
providerEnvironment,
command,
parsed.dialect,
),
)
);
} catch (cause: unknown) {
// Unreadable configuration is treated as not aligned so setup repairs it.
void cause;
@@ -185,6 +219,22 @@ export const inspectClientConfiguration = async (
if (client.configPathError !== undefined)
return { status: "invalid", remediation: client.configPathError };
if (client.format === "unsupported") return { status: "already_current" };
const files = await readClientConfigurationFiles(client);
if (!files.ok) return { status: "invalid", remediation: files.error.detail };
const policy = await readClientPolicyBlock(
client,
undefined,
clientConfigurationDesired(
client,
providerEnvironment,
command,
client.format,
),
);
if (!policy.ok)
return { status: "invalid", remediation: policy.error.detail };
if (policy.value !== undefined)
return { status: "invalid", remediation: policy.value };
const transactionPath = await resolveClientConfigTransactionPath(
client.configPath,
);
@@ -207,6 +257,20 @@ export const inspectClientConfiguration = async (
}
try {
const parsed = parseClientConfiguration(original, client.format);
const policy = await readClientPolicyBlock(
client,
parsed,
clientConfigurationDesired(
client,
providerEnvironment,
command,
parsed.dialect,
),
);
if (!policy.ok)
return { status: "invalid", remediation: policy.error.detail };
if (policy.value !== undefined)
return { status: "invalid", remediation: policy.value };
const desired = clientConfigurationDesired(
client,
providerEnvironment,
+12 -6
View File
@@ -14,7 +14,11 @@ import {
systemLinuxHopperInstallHost,
} from "./LinuxHopper.js";
import { installMacHopper, systemMacHopperInstallHost } from "./MacHopper.js";
import { supportedClients, type SetupClient } from "./SupportedClients.js";
import {
clientEvidencePaths,
supportedClients,
type SetupClient,
} from "./SupportedClients.js";
import {
skillDestinations,
canonicalSkillNeedsInstall,
@@ -168,17 +172,19 @@ export const detectClients = async (
const detected: SetupClient[] = [];
for (const candidate of supportedClients(home, platform, environment)) {
if (candidate.configPathError !== undefined) continue;
const [hasConfig, hasMarker] = await Promise.all([
exists(candidate.configPath),
candidate.markerPath === undefined ? false : exists(candidate.markerPath),
]);
if (hasConfig || hasMarker) detected.push(candidate);
if (await clientEvidencePresent(candidate)) detected.push(candidate);
}
return detected;
};
const major = (version: string): number =>
Number.parseInt(version.split(".")[0] ?? "0", 10);
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): Promise<boolean> => {
try {
await access(path);
+157 -27
View File
@@ -1,4 +1,4 @@
import { isAbsolute, join, resolve, win32 } from "node:path";
import { dirname, isAbsolute, join, resolve, win32 } from "node:path";
import { fileURLToPath } from "node:url";
import { lstatSync } from "node:fs";
@@ -7,6 +7,17 @@ export interface SetupClient {
readonly name: string;
readonly displayName?: string;
readonly configPath: string;
/** User configuration files in increasing precedence, including the write target. */
readonly configPaths?: readonly string[];
/** Read-only Gemini scopes and trust inputs; never setup write targets. */
readonly geminiSettings?: {
readonly systemPath: string;
readonly defaultsPath: string;
readonly workspaceDirectory: string;
readonly trustedFoldersPath: string;
readonly trustOverride?: boolean;
readonly platform: NodeJS.Platform;
};
/** Unresolved path evidence only; consumers must not perform I/O on it. */
readonly configPathError?: string;
readonly markerPath?: string;
@@ -39,6 +50,10 @@ interface ClientDefinition {
readonly markerPath: ClientPath;
readonly format: NonNullable<SetupClient["format"]>;
readonly skillPath?: ClientPath;
readonly configPaths?: (context: ClientPathContext) => readonly string[];
readonly geminiSettings?: (
context: ClientPathContext,
) => NonNullable<SetupClient["geminiSettings"]>;
}
const vscodeUserDirectory = ({
@@ -76,7 +91,7 @@ const claudeCodeMarkerDirectory = ({ home, env }: ClientPathContext): string =>
env.CLAUDE_CONFIG_DIR ?? join(home, ".claude");
const codexDirectory = ({ home, env }: ClientPathContext): string =>
env.CODEX_HOME ?? join(home, ".codex");
env.CODEX_HOME || join(home, ".codex");
/** Match Qwen Code's home override, including tilde and cwd-relative paths. */
const qwenCodeDirectory = ({ home, env }: ClientPathContext): string => {
@@ -98,7 +113,7 @@ const qwenCodeDirectory = ({ home, env }: ClientPathContext): string => {
};
const grokDirectory = ({ home, env }: ClientPathContext): string =>
env.GROK_HOME ?? join(home, ".grok");
env.GROK_HOME || join(home, ".grok");
/**
* Hermes Agent home. `HERMES_HOME` wins; otherwise the platform default mirrors
@@ -188,17 +203,14 @@ const ompProfile = ({ env }: ClientPathContext): string | undefined => {
/**
* The OMP agent directory whose `mcp.json` holds user-scope MCP servers. OMP
* joins PI_CONFIG_DIR to the home directory, and a named profile ignores
* PI_CODING_AGENT_DIR. OMP resolves a relative agent directory against each
* process's working directory, so only an absolute override names one file.
* PI_CODING_AGENT_DIR. Relative overrides use the setup process working directory.
*/
const ompAgentDirectory = (context: ClientPathContext): string => {
const root = join(context.home, context.env.PI_CONFIG_DIR || ".omp");
const profile = ompProfile(context);
if (profile !== undefined) return join(root, "profiles", profile, "agent");
const override = context.env.PI_CODING_AGENT_DIR;
return override !== undefined && override !== "" && isAbsolute(override)
? override
: join(root, "agent");
return override ? resolve(override) : join(root, "agent");
};
/**
@@ -242,21 +254,66 @@ const copilotDirectory = ({ home, env }: ClientPathContext): string =>
const commandCodeDirectory = ({ home }: ClientPathContext): string =>
join(home, ".commandcode");
const opencodeDirectory = ({ home, env }: ClientPathContext): string =>
join(env.XDG_CONFIG_HOME ?? join(home, ".config"), "opencode");
/**
* OpenCode appends `OPENCODE_CONFIG_DIR` only when the value is non-empty.
* An empty variable is the same as unset and leaves the XDG directory in place.
*/
const openCodeOverrideDirectory = (
env: ClientPathContext["env"],
): string | undefined => {
const directory = env.OPENCODE_CONFIG_DIR;
return directory === undefined || directory === "" ? undefined : directory;
};
const opencodeDirectory = (context: ClientPathContext): string =>
openCodeOverrideDirectory(context.env) ??
join(
context.env.XDG_CONFIG_HOME ?? join(context.home, ".config"),
"opencode",
);
/** OpenCode loads global JSON before JSONC, then explicit file and directory overrides. */
const openCodeConfigurationPaths = (
context: ClientPathContext,
): readonly string[] => {
const directory = join(
context.env.XDG_CONFIG_HOME ?? join(context.home, ".config"),
"opencode",
);
const overrideDirectory = openCodeOverrideDirectory(context.env);
const paths = [
join(directory, "config.json"),
join(directory, "opencode.json"),
join(directory, "opencode.jsonc"),
...(context.env.OPENCODE_CONFIG ? [context.env.OPENCODE_CONFIG] : []),
...(overrideDirectory !== undefined
? [
join(overrideDirectory, "opencode.json"),
join(overrideDirectory, "opencode.jsonc"),
]
: []),
];
const resolved = paths.map((path) => resolve(path));
return resolved.filter((path, index) => resolved.lastIndexOf(path) === index);
};
const existingOpenCodeConfigPath = (context: ClientPathContext): string => {
if (context.env.OPENCODE_CONFIG !== undefined)
return context.env.OPENCODE_CONFIG;
const directory = opencodeDirectory(context);
const candidates = [
"opencode.json",
"opencode.jsonc",
".opencode.json",
".opencode.jsonc",
].map((filename) => join(directory, filename));
// The selected directory is loaded after an explicit file by OpenCode V1.
const overrideDirectory = openCodeOverrideDirectory(context.env);
const candidates =
overrideDirectory !== undefined
? [
join(overrideDirectory, "opencode.json"),
join(overrideDirectory, "opencode.jsonc"),
].map((path) => resolve(path))
: context.env.OPENCODE_CONFIG
? [resolve(context.env.OPENCODE_CONFIG)]
: [
join(opencodeDirectory(context), "opencode.json"),
join(opencodeDirectory(context), "opencode.jsonc"),
].map((path) => resolve(path));
return (
candidates.find((path) => {
[...candidates].reverse().find((path) => {
try {
lstatSync(path);
return true;
@@ -268,15 +325,52 @@ const existingOpenCodeConfigPath = (context: ClientPathContext): string => {
);
}
}) ??
candidates[0] ??
join(directory, "opencode.json")
resolve(
overrideDirectory !== undefined
? join(overrideDirectory, "opencode.json")
: context.env.OPENCODE_CONFIG ||
join(opencodeDirectory(context), "opencode.json"),
)
);
};
const geminiSettings = ({
home,
platform,
env,
}: ClientPathContext): NonNullable<SetupClient["geminiSettings"]> => {
const system =
env.GEMINI_CLI_SYSTEM_SETTINGS_PATH ||
(platform === "darwin"
? "/Library/Application Support/GeminiCli/settings.json"
: platform === "win32"
? "C:\\ProgramData\\gemini-cli\\settings.json"
: "/etc/gemini-cli/settings.json");
const trustOverride =
env.GEMINI_RESTRICTED_MODE === "true" ||
env.GEMINI_CLI_TRUST_WORKSPACE === "false"
? false
: env.GEMINI_CLI_TRUST_WORKSPACE === "true"
? true
: undefined;
return {
systemPath: system,
defaultsPath:
env.GEMINI_CLI_SYSTEM_DEFAULTS_PATH ||
join(dirname(system), "system-defaults.json"),
workspaceDirectory: process.cwd(),
trustedFoldersPath:
env.GEMINI_CLI_TRUSTED_FOLDERS_PATH ||
join(env.GEMINI_CLI_HOME || home, ".gemini", "trustedFolders.json"),
...(trustOverride === undefined ? {} : { trustOverride }),
platform,
};
};
const devinDirectory = ({ home, platform, env }: ClientPathContext): string =>
platform === "win32"
? join(env.APPDATA ?? join(home, "AppData", "Roaming"), "devin")
: join(home, ".config", "devin");
: join(env.XDG_CONFIG_HOME || join(home, ".config"), "devin");
/** Stable product metadata used to derive setup discovery and documentation. */
export const SUPPORTED_CLIENT_DEFINITIONS = [
@@ -316,8 +410,13 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
{
name: "gemini_cli",
displayName: "Gemini CLI",
configPath: [".gemini", "settings.json"],
markerPath: [".gemini"],
geminiSettings,
configPath: ({ home, env }: ClientPathContext) =>
join(env.GEMINI_CLI_HOME || home, ".gemini", "settings.json"),
markerPath: ({ home, env }: ClientPathContext) =>
join(env.GEMINI_CLI_HOME || home, ".gemini"),
skillPath: ({ home, env }: ClientPathContext) =>
join(env.GEMINI_CLI_HOME || home, ".agents", "skills"),
format: "json",
},
{
@@ -339,12 +438,14 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
name: "opencode",
displayName: "OpenCode",
configPath: existingOpenCodeConfigPath,
configPaths: openCodeConfigurationPaths,
markerPath: opencodeDirectory,
format: "opencode",
},
{
name: "antigravity",
displayName: "Antigravity",
skillPath: [".gemini", "config", "skills"],
configPath: [".gemini", "config", "mcp_config.json"],
markerPath: [".gemini", "config"],
format: "json",
@@ -427,6 +528,23 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
},
] as const satisfies readonly ClientDefinition[];
/**
* Paths whose presence means doctor and setup should inspect the client.
* Layered sources count: OpenCode still loads its XDG files when a custom
* directory override does not exist.
*/
export const clientEvidencePaths = (
client: Pick<SetupClient, "configPath" | "configPaths" | "markerPath">,
): readonly string[] => [
...new Set(
[
client.configPath,
...(client.configPaths ?? []),
client.markerPath,
].filter((path): path is string => path !== undefined && path.length > 0),
),
];
const resolvePath = (path: ClientPath, context: ClientPathContext): string =>
typeof path === "function" ? path(context) : join(context.home, ...path);
@@ -437,12 +555,24 @@ export const supportedClients = (
env: ClientPathContext["env"] = process.env,
): readonly SetupClient[] => {
const context = { home, platform, env };
return SUPPORTED_CLIENT_DEFINITIONS.map((definition) => {
const definitions: readonly ClientDefinition[] = SUPPORTED_CLIENT_DEFINITIONS;
return definitions.map((definition) => {
try {
const configPath = resolvePath(definition.configPath, context);
return {
name: definition.name,
displayName: definition.displayName,
configPath: resolvePath(definition.configPath, context),
configPath,
...(definition.geminiSettings === undefined
? {}
: { geminiSettings: definition.geminiSettings(context) }),
...(definition.configPaths === undefined
? {}
: {
configPaths: [
...new Set([...definition.configPaths(context), configPath]),
],
}),
markerPath: resolvePath(definition.markerPath, context),
format: definition.format,
};
+75 -3
View File
@@ -124,11 +124,12 @@ export const runUninstall = async (
status: "retained",
detail: "Hopper is not owned by REA uninstall.",
});
const reported = collapseUninstallItems(items);
return {
status: items.some(({ status }) => status === "failed")
status: reported.some(({ status }) => status === "failed")
? "failed"
: "complete",
items,
items: reported,
};
};
@@ -171,7 +172,16 @@ export const systemUninstallHost = (
selectedHome ?? homeDirectoryFromEnvironment(environment, platform);
return {
clients: () =>
Promise.resolve(supportedClients(home, platform, environment)),
Promise.resolve(
supportedClients(home, platform, environment).flatMap((client) =>
client.configPaths === undefined
? [client]
: client.configPaths.map((configPath) => ({
...client,
configPath,
})),
),
),
inspectClient: async (client) => {
const read = await readClientConfiguration(client, fileSystem);
return read.kind === "item" && read.item.status === "failed"
@@ -452,6 +462,68 @@ const item = (
status: UninstallItem["status"],
detail: string,
): UninstallItem => ({ name, status, detail });
/**
* Layered clients are removed file by file, but callers look up one status by
* client name. A missing earlier file must not hide a later removal.
*/
const collapseUninstallItems = (
items: readonly UninstallItem[],
): UninstallItem[] => {
const collapsed: UninstallItem[] = [];
for (const next of items) {
const index = collapsed.findIndex(({ name }) => name === next.name);
const current = collapsed[index];
if (current === undefined) {
collapsed.push(next);
continue;
}
collapsed[index] = mergeUninstallItems(current, next);
}
return collapsed;
};
const mergeUninstallItems = (
current: UninstallItem,
next: UninstallItem,
): UninstallItem => {
const status = preferredUninstallStatus(current.status, next.status);
const details = [current, next]
.filter(
(entry) =>
entry.status === status ||
(status === "removed" && entry.status === "retained"),
)
.map(({ detail }) => detail);
return item(current.name, status, [...new Set(details)].join(" "));
};
const preferredUninstallStatus = (
current: UninstallItem["status"],
next: UninstallItem["status"],
): UninstallItem["status"] =>
uninstallStatusPriority(next) > uninstallStatusPriority(current)
? next
: current;
const uninstallStatusPriority = (status: UninstallItem["status"]): number => {
switch (status) {
case "failed":
return 3;
case "removed":
return 2;
case "retained":
return 1;
case "skipped":
return 0;
default: {
const exhaustive: never = status;
throw new TypeError(
`Unhandled uninstall item status: ${String(exhaustive)}`,
);
}
}
};
const registrationSchema = z
.object({ command: z.string(), args: z.array(z.string()) })
.passthrough();
+13 -3
View File
@@ -98,7 +98,11 @@ cliTest(
expect(doctor.exitCode).toBe(0);
expect(doctor.json).toMatchObject({
healthy: true,
identity: { registrations: [{ client: "pi", state: "aligned" }] },
identity: {
registrations: expect.arrayContaining([
expect.objectContaining({ client: "pi", state: "aligned" }),
]),
},
});
const repeat = await run(["setup", "--client", "pi", "--yes", "--json"]);
@@ -159,7 +163,11 @@ cliTest(
expect(doctor.exitCode).toBe(0);
expect(doctor.json).toMatchObject({
healthy: true,
identity: { registrations: [{ client: "pi", state: "aligned" }] },
identity: {
registrations: expect.arrayContaining([
expect.objectContaining({ client: "pi", state: "aligned" }),
]),
},
});
await run(["uninstall", "--json"]);
expect(
@@ -215,7 +223,9 @@ cliTest(
expect(doctorMalformed.json).toMatchObject({
healthy: false,
identity: {
registrations: [{ client: "pi", state: "invalid" }],
registrations: expect.arrayContaining([
expect.objectContaining({ client: "pi", state: "invalid" }),
]),
},
});
const removed = await runMalformed(["uninstall", "--json"]);
+786
View File
@@ -0,0 +1,786 @@
import { access, mkdir, readFile, writeFile, rm } from "node:fs/promises";
import { dirname, join } from "node:path";
import { expect } from "vitest";
import { parse } from "jsonc-parser";
import { PRODUCT_IDENTITY } from "../../../src/identity.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
const cases = [
{
client: "gemini_cli",
env: { GEMINI_CLI_HOME: "profile" },
config: "profile/.gemini/settings.json",
skill: "profile/.agents/skills",
},
{
client: "opencode",
env: { OPENCODE_CONFIG_DIR: "profile" },
config: "profile/opencode.json",
skill: ".agents/skills",
},
{
client: "opencode",
env: { OPENCODE_CONFIG_DIR: "" },
config: ".config/opencode/opencode.json",
skill: ".agents/skills",
},
{
client: "codex",
env: { CODEX_HOME: "" },
config: ".codex/config.toml",
skill: ".agents/skills",
},
{
client: "grok_build",
env: { GROK_HOME: "" },
config: ".grok/config.toml",
skill: ".agents/skills",
},
{
client: "omp",
env: { PI_CODING_AGENT_DIR: "profile" },
config: "profile/mcp.json",
skill: ".agents/skills",
},
{
client: "devin",
env: { XDG_CONFIG_HOME: "profile" },
config: "profile/devin/mcp_config.json",
skill: ".agents/skills",
},
{
client: "antigravity",
env: {},
config: ".gemini/config/mcp_config.json",
skill: ".gemini/config/skills",
},
];
for (const testCase of cases.filter(
({ client }) => process.platform !== "win32" || client !== "devin",
)) {
cliTest(
`${testCase.client} lifecycle follows the selected roots (${testCase.config})`,
async ({ cli }) => {
const home = await createTestTempDirectory("rea-client-profile-");
const environment: NodeJS.ProcessEnv = { HOME: home, USERPROFILE: home };
for (const [key, value] of Object.entries(testCase.env))
environment[key] = value === "profile" ? join(home, value) : value;
// OMP's relative override is relative to the caller, not silently ignored.
if (testCase.client === "omp")
environment.PI_CODING_AGENT_DIR = "profile";
const config = join(home, testCase.config);
const skillDirectory = join(
home,
testCase.skill,
"reverse-engineer-anything",
);
const run = (arguments_: readonly string[]) =>
cli.run({
arguments: arguments_,
cwd: home,
environment,
timeoutMs: 20_000,
});
const planned = await run([
"setup",
"--client",
testCase.client,
"--dry-run",
"--json",
]);
expect(planned.exitCode).toBe(0);
expect(planned.json).toMatchObject({
status: "planned",
plannedActions: expect.arrayContaining([
expect.objectContaining({ target: config }),
expect.objectContaining({ target: skillDirectory }),
]),
});
await expect(access(config)).rejects.toMatchObject({ code: "ENOENT" });
const setup = await run([
"setup",
"--client",
testCase.client,
"--yes",
"--json",
]);
expect(setup.exitCode).toBe(0);
expect(setup.json).toMatchObject({ status: "ready" });
await expect(access(config)).resolves.toBeUndefined();
expect(
await readFile(join(skillDirectory, "SKILL.md"), "utf8"),
).toContain("reverse-engineer-anything");
await expect(access(join(home, "config.toml"))).rejects.toMatchObject({
code: "ENOENT",
});
const doctor = await run([
"doctor",
"--client",
testCase.client,
"--skill",
"--json",
]);
expect(doctor.exitCode).toBe(0);
expect(doctor.json).toMatchObject({
healthy: true,
identity: { skill: { state: "aligned" } },
});
if (testCase.client === "grok_build") {
const original = await readFile(config, "utf8");
await writeFile(
config,
original.replace(
"[mcp_servers.rea]",
"[mcp_servers.rea]\ndisabled = true",
),
);
expect(
(await run(["doctor", "--client", "grok_build", "--json"])).exitCode,
).toBe(0);
await writeFile(config, original);
}
const repeat = await run([
"setup",
"--client",
testCase.client,
"--yes",
"--json",
]);
expect(repeat.json).toMatchObject({
status: "ready",
appliedActions: [],
});
const uninstall = await run(["uninstall", "--json"]);
expect(uninstall.exitCode).toBe(0);
expect(uninstall.json).toMatchObject({ status: "complete" });
await expect(
access(join(skillDirectory, "SKILL.md")),
).rejects.toMatchObject({ code: "ENOENT" });
},
);
}
for (const policy of [{ excluded: ["rea"] }, { allowed: ["other"] }]) {
cliTest(
`Gemini refuses a registration blocked by ${Object.keys(policy)[0]}`,
async ({ cli }) => {
const home = await createTestTempDirectory("rea-gemini-policy-");
const config = join(home, ".gemini/settings.json");
const original = JSON.stringify({
mcp: policy,
mcpServers: {
rea: {
command: "npx",
args: ["-y", PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"],
},
},
});
await mkdir(dirname(config), { recursive: true });
await writeFile(config, original);
const run = (arguments_: readonly string[]) =>
cli.run({
arguments: arguments_,
cwd: home,
environment: { HOME: home, USERPROFILE: home },
timeoutMs: 20_000,
});
const setup = await run([
"setup",
"--client",
"gemini_cli",
"--yes",
"--json",
]);
expect(setup.exitCode).toBe(1);
expect(setup.json).toMatchObject({
status: "needs_human",
plannedActions: [],
appliedActions: [],
remediation: expect.stringContaining("mcp." + Object.keys(policy)[0]),
});
expect(await readFile(config, "utf8")).toBe(original);
await expect(access(config + ".rea.backup")).rejects.toMatchObject({
code: "ENOENT",
});
const doctor = await run(["doctor", "--client", "gemini_cli", "--json"]);
expect(doctor.exitCode).toBe(1);
expect(doctor.json).toMatchObject({
healthy: false,
identity: {
registrations: expect.arrayContaining([
expect.objectContaining({
client: "gemini_cli",
state: "stale",
remediation: expect.stringContaining("mcp."),
}),
]),
},
});
},
);
}
cliTest(
"OpenCode diagnoses merged JSON/JSONC and removes owned entries from both files",
async ({ cli }) => {
const home = await createTestTempDirectory("rea-opencode-layers-");
const directory = join(home, ".config/opencode");
await mkdir(directory, { recursive: true });
const lower = join(directory, "opencode.json");
const higher = join(directory, "opencode.jsonc");
const originalLower = JSON.stringify({
mcp: {
rea: {
type: "local",
command: [
"npx",
"-y",
PRODUCT_IDENTITY.registrationPackageSpecifier,
"mcp",
],
enabled: true,
},
other: { type: "local", command: ["other"] },
},
});
const originalHigher =
'// keep this comment\n{"mcp":{"rea":{"enabled":false}},"theme":"system"}\n';
await writeFile(lower, originalLower);
await writeFile(higher, originalHigher);
const run = (arguments_: readonly string[]) =>
cli.run({
arguments: arguments_,
cwd: home,
environment: { HOME: home, USERPROFILE: home },
timeoutMs: 20_000,
});
const before = await run(["doctor", "--client", "opencode", "--json"]);
expect(before.exitCode).toBe(1);
expect(before.json).toMatchObject({
identity: {
registrations: expect.arrayContaining([
expect.objectContaining({ client: "opencode", state: "stale" }),
]),
},
});
const setup = await run([
"setup",
"--client",
"opencode",
"--yes",
"--json",
]);
expect(setup.exitCode).toBe(0);
expect(setup.json).toMatchObject({ status: "ready" });
expect(await readFile(lower, "utf8")).toBe(originalLower);
expect(await readFile(higher + ".rea.backup", "utf8")).toBe(originalHigher);
expect(await readFile(higher, "utf8")).toContain("// keep this comment");
expect(
(await run(["doctor", "--client", "opencode", "--json"])).exitCode,
).toBe(0);
const uninstall = await run(["uninstall", "--json"]);
expect(uninstall.exitCode).toBe(0);
const report = uninstall.json as {
items?: readonly { name: string; status: string }[];
};
const opencodeItems = report.items?.filter(
({ name }) => name === "opencode",
);
expect(opencodeItems).toEqual([
expect.objectContaining({ status: "removed" }),
]);
expect(parse(await readFile(lower, "utf8"))).toEqual({
mcp: { other: { type: "local", command: ["other"] } },
});
expect(parse(await readFile(higher, "utf8"))).toEqual({
mcp: {},
theme: "system",
});
},
);
cliTest(
"malformed lower-priority OpenCode config blocks setup before any writes",
async ({ cli }) => {
const home = await createTestTempDirectory("rea-opencode-invalid-layer-");
const directory = join(home, ".config/opencode");
await mkdir(directory, { recursive: true });
const lower = join(directory, "opencode.json");
const higher = join(directory, "opencode.jsonc");
await writeFile(lower, "malformed");
await writeFile(higher, "{}");
const result = await cli.run({
arguments: ["setup", "--client", "opencode", "--yes", "--json"],
cwd: home,
environment: { HOME: home, USERPROFILE: home },
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
status: "needs_human",
plannedActions: [],
appliedActions: [],
});
expect(await readFile(lower, "utf8")).toBe("malformed");
expect(await readFile(higher, "utf8")).toBe("{}");
await expect(access(higher + ".rea.backup")).rejects.toMatchObject({
code: "ENOENT",
});
},
);
cliTest(
"Gemini system policy blocks a new registration without modifying managed files",
async ({ cli }) => {
const home = await createTestTempDirectory("rea-gemini-managed-");
const policy = join(home, "managed.json");
const original = JSON.stringify({
mcp: { excluded: ["rea"] },
unrelated: true,
});
await writeFile(policy, original);
const environment = {
HOME: home,
USERPROFILE: home,
GEMINI_CLI_SYSTEM_SETTINGS_PATH: policy,
GEMINI_CLI_SYSTEM_DEFAULTS_PATH: join(home, "defaults.json"),
};
const setup = await cli.run({
arguments: ["setup", "--client", "gemini_cli", "--yes", "--json"],
cwd: home,
environment,
});
expect(setup.exitCode).toBe(1);
expect(setup.json).toMatchObject({
status: "needs_human",
plannedActions: [],
appliedActions: [],
remediation: expect.stringContaining("mcp.excluded"),
});
expect(await readFile(policy, "utf8")).toBe(original);
await expect(
access(join(home, ".gemini/settings.json")),
).rejects.toMatchObject({ code: "ENOENT" });
await expect(access(policy + ".rea.backup")).rejects.toMatchObject({
code: "ENOENT",
});
},
);
cliTest(
"Gemini intersects system/default/user allowlists and accepts an empty effective allowlist",
async ({ cli }) => {
const home = await createTestTempDirectory("rea-gemini-allowlists-");
const system = join(home, "system.json");
const defaults = join(home, "defaults.json");
const user = join(home, ".gemini/settings.json");
await mkdir(dirname(user), { recursive: true });
await writeFile(
system,
JSON.stringify({ mcp: { allowed: ["rea", "other"] } }),
);
await writeFile(defaults, JSON.stringify({ mcp: { allowed: ["other"] } }));
await writeFile(
user,
JSON.stringify({ mcp: { allowed: ["rea", "other"] } }),
);
const environment = {
HOME: home,
USERPROFILE: home,
GEMINI_CLI_SYSTEM_SETTINGS_PATH: system,
GEMINI_CLI_SYSTEM_DEFAULTS_PATH: defaults,
};
const run = () =>
cli.run({
arguments: ["setup", "--client", "gemini_cli", "--yes", "--json"],
cwd: home,
environment,
});
const blocked = await run();
expect(blocked.exitCode).toBe(1);
expect(blocked.json).toMatchObject({
status: "needs_human",
appliedActions: [],
});
await writeFile(defaults, JSON.stringify({ mcp: { allowed: ["rea"] } }));
const allowed = await run();
expect(allowed.exitCode).toBe(0);
expect(allowed.json).toMatchObject({ status: "ready" });
expect(JSON.parse(await readFile(defaults, "utf8"))).toEqual({
mcp: { allowed: ["rea"] },
});
// The consumer treats an empty consolidated allowlist as unrestricted.
await writeFile(defaults, JSON.stringify({ mcp: { allowed: [] } }));
const empty = await run();
expect(empty.exitCode).toBe(0);
expect(empty.json).toMatchObject({ status: "ready", appliedActions: [] });
},
);
cliTest(
"OpenCode doctor sees a global registration when the custom directory is absent",
async ({ cli }) => {
const home = await createTestTempDirectory("rea-opencode-absent-custom-");
const global = join(home, ".config/opencode/opencode.json");
const custom = join(home, "missing-custom");
await mkdir(dirname(global), { recursive: true });
await writeFile(
global,
JSON.stringify({
mcp: {
rea: {
type: "local",
command: [
"npx",
"-y",
PRODUCT_IDENTITY.registrationPackageSpecifier,
"mcp",
],
enabled: true,
},
},
}),
);
const run = (args: readonly string[]) =>
cli.run({
arguments: args,
cwd: home,
environment: {
HOME: home,
USERPROFILE: home,
OPENCODE_CONFIG_DIR: custom,
},
});
const doctor = await run(["doctor", "--client", "opencode", "--json"]);
expect(doctor.exitCode).toBe(0);
expect(doctor.json).toMatchObject({
identity: {
registrations: expect.arrayContaining([
expect.objectContaining({ client: "opencode", state: "aligned" }),
]),
},
});
const planned = await run([
"setup",
"--client",
"opencode",
"--dry-run",
"--json",
]);
expect(planned.json).toMatchObject({
clientStates: expect.arrayContaining([
expect.objectContaining({
client: expect.objectContaining({ name: "opencode" }),
detected: true,
}),
]),
});
await expect(access(custom)).rejects.toMatchObject({ code: "ENOENT" });
},
);
cliTest(
"OpenCode retains the XDG global layer when a custom directory is selected",
async ({ cli }) => {
const home = await createTestTempDirectory("rea-opencode-global-custom-");
const global = join(home, ".config/opencode/opencode.json");
const custom = join(home, "custom/opencode.jsonc");
await mkdir(dirname(global), { recursive: true });
await mkdir(dirname(custom), { recursive: true });
const owned = {
type: "local",
command: [
"npx",
"-y",
PRODUCT_IDENTITY.registrationPackageSpecifier,
"mcp",
],
enabled: true,
};
const original = JSON.stringify({
mcp: { rea: owned, other: { type: "local", command: ["other"] } },
});
await writeFile(global, original);
await writeFile(custom, "{}");
const run = (args: readonly string[]) =>
cli.run({
arguments: args,
cwd: home,
environment: {
HOME: home,
USERPROFILE: home,
OPENCODE_CONFIG_DIR: dirname(custom),
},
});
const doctor = await run(["doctor", "--client", "opencode", "--json"]);
expect(doctor.json).toMatchObject({
identity: {
registrations: expect.arrayContaining([
expect.objectContaining({ client: "opencode", state: "aligned" }),
]),
},
});
await writeFile(global, "malformed");
expect(
(await run(["setup", "--client", "opencode", "--yes", "--json"])).json,
).toMatchObject({
status: "needs_human",
plannedActions: [],
appliedActions: [],
remediation: expect.stringContaining(global),
});
expect(await readFile(custom, "utf8")).toBe("{}");
await writeFile(global, original);
expect(
(await run(["setup", "--client", "opencode", "--yes", "--json"]))
.exitCode,
).toBe(0);
expect((await run(["uninstall", "--json"])).exitCode).toBe(0);
expect(parse(await readFile(global, "utf8"))).toEqual({
mcp: { other: { type: "local", command: ["other"] } },
});
expect(parse(await readFile(custom, "utf8"))).toEqual({ mcp: {} });
},
);
cliTest(
"Gemini blocks a conflicting system definition and accepts an identical managed entry",
async ({ cli }) => {
const home = await createTestTempDirectory("rea-gemini-managed-server-");
const user = join(home, ".gemini/settings.json");
const system = join(home, "system.json");
const defaults = join(home, "defaults.json");
const environment = {
HOME: home,
USERPROFILE: home,
GEMINI_CLI_SYSTEM_SETTINGS_PATH: system,
GEMINI_CLI_SYSTEM_DEFAULTS_PATH: defaults,
};
const run = (args: readonly string[]) =>
cli.run({ arguments: args, cwd: home, environment });
const command = ["setup", "--client", "gemini_cli", "--yes", "--json"];
// A user registration overrides a default definition, including its entire env object.
await writeFile(
defaults,
JSON.stringify({
mcpServers: {
rea: { command: "managed-default", env: { EXTRA: "default" } },
},
}),
);
expect((await run(command)).exitCode).toBe(0);
const original = await readFile(user, "utf8");
const managed = JSON.stringify({
mcpServers: { rea: { command: "managed-override", args: [] } },
unrelated: true,
});
await writeFile(system, managed);
expect((await run(command)).json).toMatchObject({
status: "needs_human",
plannedActions: [],
appliedActions: [],
remediation: expect.stringContaining(system),
});
expect(
(await run(["doctor", "--client", "gemini_cli", "--json"])).json,
).toMatchObject({
healthy: false,
identity: {
registrations: expect.arrayContaining([
expect.objectContaining({
client: "gemini_cli",
state: "stale",
remediation: expect.stringContaining("mcpServers.rea"),
}),
]),
},
});
expect(await readFile(user, "utf8")).toBe(original);
expect(await readFile(system, "utf8")).toBe(managed);
const aligned = JSON.stringify({
mcpServers: JSON.parse(original).mcpServers,
});
await writeFile(system, aligned);
expect((await run(command)).json).toMatchObject({
status: "ready",
appliedActions: [],
});
expect(
(await run(["doctor", "--client", "gemini_cli", "--json"])).exitCode,
).toBe(0);
expect(await readFile(system, "utf8")).toBe(aligned);
await expect(access(system + ".rea.backup")).rejects.toMatchObject({
code: "ENOENT",
});
},
);
cliTest(
"Gemini workspace policy follows persisted trust and explicit trust overrides",
async ({ cli }) => {
const home = await createTestTempDirectory("rea-gemini-workspace-policy-");
const project = join(home, "project");
const workspace = join(project, ".gemini/settings.json");
const trust = join(home, "trust.json");
await mkdir(dirname(workspace), { recursive: true });
const environment: NodeJS.ProcessEnv = {
HOME: home,
USERPROFILE: home,
GEMINI_CLI_SYSTEM_SETTINGS_PATH: join(home, "system.json"),
GEMINI_CLI_SYSTEM_DEFAULTS_PATH: join(home, "defaults.json"),
GEMINI_CLI_TRUSTED_FOLDERS_PATH: trust,
};
const run = (args: readonly string[]) =>
cli.run({ arguments: args, cwd: project, environment });
const command = ["setup", "--client", "gemini_cli", "--yes", "--json"];
const original = JSON.stringify({
mcp: { excluded: ["rea"] },
unrelated: true,
});
await writeFile(workspace, original);
await writeFile(
trust,
JSON.stringify({ [home]: "TRUST_FOLDER", [project]: "DO_NOT_TRUST" }),
);
expect((await run(command)).exitCode).toBe(0);
await writeFile(
trust,
JSON.stringify({ [join(project, "child")]: "TRUST_PARENT" }),
);
expect((await run(command)).json).toMatchObject({
status: "needs_human",
appliedActions: [],
remediation: expect.stringContaining(workspace),
});
expect(
(await run(["doctor", "--client", "gemini_cli", "--json"])).json,
).toMatchObject({
healthy: false,
identity: {
registrations: expect.arrayContaining([
expect.objectContaining({
client: "gemini_cli",
state: "stale",
remediation: expect.stringContaining("mcp.excluded"),
}),
]),
},
});
environment.GEMINI_CLI_TRUST_WORKSPACE = "false";
expect((await run(command)).json).toMatchObject({
status: "ready",
appliedActions: [],
});
environment.GEMINI_CLI_TRUST_WORKSPACE = "true";
await writeFile(workspace, JSON.stringify({ mcp: { allowed: ["other"] } }));
expect((await run(command)).json).toMatchObject({
status: "needs_human",
remediation: expect.stringContaining("mcp.allowed"),
});
// Restricted mode takes precedence over an explicit trust opt-in.
environment.GEMINI_RESTRICTED_MODE = "true";
expect((await run(command)).json).toMatchObject({
status: "ready",
appliedActions: [],
});
expect(JSON.parse(await readFile(workspace, "utf8"))).toEqual({
mcp: { allowed: ["other"] },
});
await expect(access(workspace + ".rea.backup")).rejects.toMatchObject({
code: "ENOENT",
});
},
);
cliTest(
"Gemini workspace definitions use shallow precedence and remain read-only",
async ({ cli }) => {
const home = await createTestTempDirectory("rea-gemini-workspace-server-");
const project = join(home, "project");
const workspace = join(project, ".gemini/settings.json");
const system = join(home, "system.json");
await mkdir(dirname(workspace), { recursive: true });
const environment: NodeJS.ProcessEnv = {
HOME: home,
USERPROFILE: home,
GEMINI_CLI_SYSTEM_SETTINGS_PATH: system,
GEMINI_CLI_SYSTEM_DEFAULTS_PATH: join(home, "defaults.json"),
GEMINI_CLI_TRUST_WORKSPACE: "true",
};
const run = (args: readonly string[]) =>
cli.run({ arguments: args, cwd: project, environment });
const command = ["setup", "--client", "gemini_cli", "--yes", "--json"];
const original = JSON.stringify({
mcpServers: { rea: { command: "project-server", args: [] } },
});
await writeFile(workspace, original);
expect((await run(command)).json).toMatchObject({
status: "needs_human",
plannedActions: [],
appliedActions: [],
remediation: expect.stringContaining(workspace),
});
await expect(
access(join(home, ".gemini/settings.json")),
).rejects.toMatchObject({ code: "ENOENT" });
// Obtain this setup invocation's actual command while workspace settings are untrusted.
environment.GEMINI_CLI_TRUST_WORKSPACE = "false";
expect((await run(command)).exitCode).toBe(0);
const user = JSON.parse(
await readFile(join(home, ".gemini/settings.json"), "utf8"),
);
environment.GEMINI_CLI_TRUST_WORKSPACE = "true";
// System's entire entry wins over the workspace entry.
await writeFile(system, JSON.stringify({ mcpServers: user.mcpServers }));
expect((await run(command)).exitCode).toBe(0);
expect(
(await run(["doctor", "--client", "gemini_cli", "--json"])).exitCode,
).toBe(0);
expect(await readFile(workspace, "utf8")).toBe(original);
await expect(access(workspace + ".rea.backup")).rejects.toMatchObject({
code: "ENOENT",
});
},
);
cliTest(
"Gemini validates trust configuration and honors a disabled folder trust feature",
async ({ cli }) => {
const home = await createTestTempDirectory("rea-gemini-trust-invalid-");
const project = join(home, "project");
const workspace = join(project, ".gemini/settings.json");
const user = join(home, ".gemini/settings.json");
const trust = join(home, ".gemini/trustedFolders.json");
await mkdir(dirname(workspace), { recursive: true });
await mkdir(dirname(user), { recursive: true });
await writeFile(workspace, JSON.stringify({ mcp: { excluded: ["rea"] } }));
await writeFile(trust, JSON.stringify({ [project]: "INVALID" }));
const run = () =>
cli.run({
arguments: ["setup", "--client", "gemini_cli", "--yes", "--json"],
cwd: project,
environment: {
HOME: home,
USERPROFILE: home,
GEMINI_CLI_SYSTEM_SETTINGS_PATH: join(home, "system.json"),
GEMINI_CLI_SYSTEM_DEFAULTS_PATH: join(home, "defaults.json"),
},
});
expect((await run()).json).toMatchObject({
status: "needs_human",
plannedActions: [],
appliedActions: [],
remediation: expect.stringContaining(trust),
});
await expect(access(user)).rejects.toMatchObject({ code: "ENOENT" });
await rm(trust);
await writeFile(
user,
JSON.stringify({ security: { folderTrust: { enabled: false } } }),
);
expect((await run()).json).toMatchObject({
status: "needs_human",
remediation: expect.stringContaining("mcp.excluded"),
});
},
);
@@ -38,9 +38,9 @@ describe("OMP configuration paths", () => {
expect(ompPath({ PI_CODING_AGENT_DIR: "/custom/agent" })?.configPath).toBe(
"/custom/agent/mcp.json",
);
// A relative override names a different file in every working directory.
// Match OMP by resolving the override against the caller working directory.
expect(ompPath({ PI_CODING_AGENT_DIR: "agent" })?.configPath).toBe(
"/home/a/.omp/agent/mcp.json",
resolve("agent", "mcp.json"),
);
});
+7
View File
@@ -17,11 +17,18 @@ export const CLIENT_LOCATION_ENVIRONMENT = [
"CODEX_HOME",
"COPILOT_HOME",
"GROK_HOME",
"GEMINI_CLI_HOME",
"GEMINI_CLI_TRUST_WORKSPACE",
"GEMINI_RESTRICTED_MODE",
"GEMINI_CLI_TRUSTED_FOLDERS_PATH",
"GEMINI_CLI_SYSTEM_SETTINGS_PATH",
"GEMINI_CLI_SYSTEM_DEFAULTS_PATH",
"HERMES_DATA_DIR_SUFFIX",
"HERMES_HOME",
"LOCALAPPDATA",
"OMP_PROFILE",
"OPENCODE_CONFIG",
"OPENCODE_CONFIG_DIR",
"PI_CODING_AGENT_DIR",
"PI_CONFIG_DIR",
"PI_PROFILE",