fix(setup): derive client config roots from each client's own reader (#10596)

Configuration roots read from XDG_CONFIG_HOME, VSCODE_APPDATA and
VSCODE_PORTABLE now come from one module whose functions reproduce a named
reader: xdg-basedir (OpenCode, Kilo), the XDG rule used by Rust dirs and
etcetera (Zed, Goose), and VS Code's getUserDataPath. mcp add's ad hoc
platformPaths() is removed. Zed no longer takes a relative XDG_CONFIG_HOME
that Zed ignores, and the Cline extension and Kilo's VS Code marker follow
VS Code's portable and VSCODE_APPDATA selection.
This commit is contained in:
morluto authored and GitHub committed 2026-11-14 14:40:10 -05:00
1 parent a09d3bed94
commit f5c8c1f9e0
6 files changed
+197 -66

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, it } from "vitest";
import {
absoluteXdgConfigHome,
xdgConfigHomeOrDefault,
} from "./ClientConfigRoots.js";
const home = "/home/user";
it.each([
[undefined, "/home/user/.config", "/home/user/.config"],
["", "/home/user/.config", "/home/user/.config"],
["relative", "relative", "/home/user/.config"],
["/custom", "/custom", "/custom"],
])(
"reads XDG_CONFIG_HOME=%j as xdg-basedir %s and the XDG rule %s",
(value, basedir, specification) => {
const env = value === undefined ? {} : { XDG_CONFIG_HOME: value };
expect(xdgConfigHomeOrDefault({ home, env })).toBe(basedir);
expect(absoluteXdgConfigHome({ home, env })).toBe(specification);
},
);
// Client root semantics differ per reader, so they are derived only here.
it("keeps client configuration-root variables in one owner", async () => {
const directory = fileURLToPath(new URL(".", import.meta.url));
const offenders: string[] = [];
for (const name of await readdir(directory)) {
if (
!name.endsWith(".ts") ||
name.endsWith(".test.ts") ||
name === "ClientConfigRoots.ts"
)
continue;
const text = await readFile(join(directory, name), "utf8");
if (/\.(?:XDG_CONFIG_HOME|VSCODE_APPDATA|VSCODE_PORTABLE)\b/u.test(text))
offenders.push(name);
}
expect(offenders).toEqual([]);
});
+74
View File
@@ -0,0 +1,74 @@
import { isAbsolute, join, posix, resolve, win32 } from "node:path";
import { windowsAppDataRoots } from "./MsixPackageStorage.js";
/**
* Configuration roots that clients derive from environment variables. Each
* function reproduces one reader's semantics for empty and relative values,
* named after the code that owns them, so a client location states which rule
* it follows instead of re-deriving it.
*/
export interface ClientRootContext {
readonly home: string;
readonly platform: NodeJS.Platform;
readonly env: Readonly<NodeJS.ProcessEnv>;
}
/**
* `XDG_CONFIG_HOME || ~/.config`: an empty value is unset and a relative value
* is kept, resolving against the reader's working directory. This is the
* xdg-basedir package (OpenCode, Kilo) and VS Code's `getDefaultUserDataPath`
* on Linux. Clients that do not document their reader also use it; it is not
* established for them.
*/
export const xdgConfigHomeOrDefault = ({
home,
env,
}: Pick<ClientRootContext, "home" | "env">): string =>
env.XDG_CONFIG_HOME || join(home, ".config");
/**
* The XDG Base Directory rule: only an absolute `XDG_CONFIG_HOME` counts;
* empty and relative values are invalid and fall back to `~/.config`. This is
* Rust `dirs::config_dir` on Linux (Zed) and etcetera's XDG strategy (Goose).
*/
export const absoluteXdgConfigHome = ({
home,
env,
}: Pick<ClientRootContext, "home" | "env">): string => {
const value = env.XDG_CONFIG_HOME;
return value !== undefined && posix.isAbsolute(value)
? value
: join(home, ".config");
};
/**
* VS Code's `getUserDataPath`: non-empty `VSCODE_PORTABLE` selects portable
* storage, then non-empty `VSCODE_APPDATA`, then the platform default. A
* relative result resolves against `VSCODE_CWD`. Extensions such as Cline
* keep their global storage under this directory.
*/
export const vscodeUserDirectory = ({
home,
platform,
env,
}: ClientRootContext): string => {
const userData = env.VSCODE_PORTABLE
? join(env.VSCODE_PORTABLE, "user-data")
: env.VSCODE_APPDATA
? join(env.VSCODE_APPDATA, "Code")
: platform === "win32"
? join(windowsAppDataRoots(home, env).roaming, "Code")
: platform === "darwin"
? join(home, "Library", "Application Support", "Code")
: join(xdgConfigHomeOrDefault({ home, env }), "Code");
// The catalog can describe Windows clients on another host; retain its
// drive-absolute paths instead of resolving them against that host cwd.
const absoluteUserData =
platform === "win32" && win32.isAbsolute(userData) && !isAbsolute(userData)
? userData
: isAbsolute(userData)
? resolve(userData)
: resolve(env.VSCODE_CWD || process.cwd(), userData);
return join(absoluteUserData, "User");
};
+23 -1
View File
@@ -246,7 +246,7 @@ it("uses Cline's literal fallback for an empty POSIX HOME and refuses unknown sy
);
});
it("selects Goose and Zed native settings paths for their platforms and overrides", async () => {
it("selects Goose, Zed and Cline extension native settings paths for their platforms and overrides", async () => {
const cases = [
{
agent: "goose",
@@ -278,6 +278,28 @@ it("selects Goose and Zed native settings paths for their platforms and override
environment: { XDG_CONFIG_HOME: "/custom" },
expected: "/custom/zed/settings.json",
},
// Zed's dirs::config_dir ignores a relative XDG_CONFIG_HOME.
{
agent: "zed",
platform: "linux" as const,
environment: { XDG_CONFIG_HOME: "relative" },
expected: "/home/user/.config/zed/settings.json",
},
// The Cline extension stores settings in VS Code's selected user data.
{
agent: "cline",
platform: "linux" as const,
environment: { VSCODE_APPDATA: "/vscode-data" },
expected:
"/vscode-data/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
},
{
agent: "cline",
platform: "darwin" as const,
environment: { VSCODE_PORTABLE: "/portable" },
expected:
"/portable/user-data/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
},
];
for (const { agent, platform, environment, expected } of cases) {
const resolved = await resolveMcpRegistrationClients(
@@ -1,5 +1,9 @@
import { dirname, join, relative } from "node:path";
import { ConfigurationError } from "../domain/configurationErrors.js";
import {
vscodeUserDirectory,
xdgConfigHomeOrDefault,
} from "./ClientConfigRoots.js";
import { parseClientConfiguration } from "./ClientConfigurationDocument.js";
import {
resolveKiloRegistrationWorktree,
@@ -7,7 +11,7 @@ import {
} from "./McpRegistrationGit.js";
import {
selectedPath,
platformPaths,
rootContext,
present,
type McpRegistrationLocationContext,
type McpRegistrationLocation,
@@ -67,8 +71,12 @@ const kiloInline = (
);
}
};
/** Kilo's Global.Path.config: xdg-basedir's config home joined with "kilo". */
const kiloRoot = (context: McpRegistrationLocationContext): string =>
selectedPath(join(platformPaths(context).config, "kilo"), context);
selectedPath(
join(xdgConfigHomeOrDefault(rootContext(context)), "kilo"),
context,
);
const kiloHomeDirectories = async (
context: McpRegistrationLocationContext,
): Promise<KiloConfigDirectory[]> => {
@@ -162,7 +170,7 @@ export const kiloLocation = async (
markers: [
root,
join(
platformPaths(context).vscode,
vscodeUserDirectory(rootContext(context)),
"globalStorage",
"kilocode.kilo-code",
),
+37 -26
View File
@@ -3,6 +3,12 @@ import { dirname, join, posix, win32 } from "node:path";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import {
absoluteXdgConfigHome,
vscodeUserDirectory,
type ClientRootContext,
} from "./ClientConfigRoots.js";
import { windowsAppDataRoots } from "./MsixPackageStorage.js";
import type { SetupClient, SupportedClientName } from "./SupportedClients.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import type { ClientConfigurationDocument } from "./ClientConfigurationDocument.js";
@@ -64,17 +70,20 @@ export const fromSetup =
};
};
export const platformPaths = (context: McpRegistrationLocationContext) => {
const { home, platform, environment } = context;
const config = environment.XDG_CONFIG_HOME || join(home, ".config");
const appSupport =
platform === "win32"
? environment.APPDATA || win32.join(home, "AppData", "Roaming")
: platform === "darwin"
? join(home, "Library", "Application Support")
: config;
return { config, appSupport, vscode: join(appSupport, "Code", "User") };
};
/** The client-root context of one registration selection. */
export const rootContext = ({
home,
platform,
environment,
}: McpRegistrationLocationContext): ClientRootContext => ({
home,
platform,
env: environment,
});
/** Windows roaming application data, read as setup reads it. */
const windowsRoaming = (context: McpRegistrationLocationContext): string =>
windowsAppDataRoots(context.home, context.environment).roaming;
const location = (
path: string,
@@ -112,7 +121,7 @@ export const clineLocation = async (
): Promise<McpRegistrationGlobalLocation> =>
location(
join(
platformPaths(context).vscode,
vscodeUserDirectory(rootContext(context)),
"globalStorage",
"saoudrizwan.claude-dev",
"settings",
@@ -129,18 +138,12 @@ export const gooseLocation = async (
const selected = pathApi.join(root, "config", "config.yaml");
return location(selected, selected);
}
const xdg = environment.XDG_CONFIG_HOME;
// etcetera's Windows and XDG strategies; only an absolute XDG root counts.
const path =
platform === "win32"
? join(
platformPaths(context).appSupport,
"Block",
"goose",
"config",
"config.yaml",
)
? join(windowsRoaming(context), "Block", "goose", "config", "config.yaml")
: join(
xdg && posix.isAbsolute(xdg) ? xdg : join(home, ".config"),
absoluteXdgConfigHome({ home, env: environment }),
"goose",
"config.yaml",
);
@@ -186,14 +189,22 @@ export const mcporterLocation = async (
export const zedLocation = async (
context: McpRegistrationLocationContext,
): Promise<McpRegistrationGlobalLocation> =>
// Zed's paths::config_dir. On Windows it reads the RoamingAppData known
// folder, which APPDATA normally names; on Linux, `dirs::config_dir`
// admits only an absolute XDG_CONFIG_HOME.
location(
context.platform === "darwin"
? join(context.home, ".config", "zed", "settings.json")
: join(
platformPaths(context).appSupport,
context.platform === "win32" ? "Zed" : "zed",
"settings.json",
),
: context.platform === "win32"
? join(windowsRoaming(context), "Zed", "settings.json")
: join(
absoluteXdgConfigHome({
home: context.home,
env: context.environment,
}),
"zed",
"settings.json",
),
);
export const fxLocation = async (
+9 -36
View File
@@ -13,6 +13,10 @@ import {
} from "./HermesClientHome.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import { appendCallerPath } from "../filesystem/callerPath.js";
import {
vscodeUserDirectory,
xdgConfigHomeOrDefault,
} from "./ClientConfigRoots.js";
import { resolveWindowsClaudeDesktopLocation } from "./ClaudeDesktopConfigLocation.js";
import { resolveClineCliSettingsPath } from "./ClineCliLocation.js";
import {
@@ -145,37 +149,6 @@ interface ClientDefinitionBase {
) => NonNullable<SetupClient["geminiSettings"]>;
}
/** XDG treats an empty config-home value as unset. */
const xdgConfigHome = ({ home, env }: ClientPathContext): string =>
env.XDG_CONFIG_HOME || join(home, ".config");
const vscodeUserDirectory = ({
home,
platform,
env,
}: ClientPathContext): string => {
// Match VS Code's user-data selection before appending the User scope.
// Empty overrides are unset; portable storage takes precedence over appdata.
const userData = env.VSCODE_PORTABLE
? join(env.VSCODE_PORTABLE, "user-data")
: env.VSCODE_APPDATA
? join(env.VSCODE_APPDATA, "Code")
: platform === "win32"
? join(windowsAppDataRoots(home, env).roaming, "Code")
: platform === "darwin"
? join(home, "Library", "Application Support", "Code")
: join(xdgConfigHome({ home, platform, env }), "Code");
// The catalog can describe Windows clients on another host; retain its
// drive-absolute paths instead of resolving them against that host cwd.
const absoluteUserData =
platform === "win32" && win32.isAbsolute(userData) && !isAbsolute(userData)
? userData
: isAbsolute(userData)
? resolve(userData)
: resolve(env.VSCODE_CWD || process.cwd(), userData);
return join(absoluteUserData, "User");
};
const traeUserDirectory = ({
home,
platform,
@@ -185,7 +158,7 @@ const traeUserDirectory = ({
return join(windowsAppDataRoots(home, env).roaming, "Trae", "User");
if (platform === "darwin")
return join(home, "Library", "Application Support", "Trae", "User");
return join(xdgConfigHome({ home, platform, env }), "Trae", "User");
return join(xdgConfigHomeOrDefault({ home, env }), "Trae", "User");
};
/** Windows packaging decides which file Claude Desktop reads; see its resolver. */
@@ -196,7 +169,7 @@ const claudeDesktopLocation = (
return resolveWindowsClaudeDesktopLocation(context.home, context.env);
const directory =
context.platform === "linux"
? join(xdgConfigHome(context), "Claude")
? join(xdgConfigHomeOrDefault(context), "Claude")
: join(context.home, "Library", "Application Support", "Claude");
return ok({
configPath: join(directory, "claude_desktop_config.json"),
@@ -468,13 +441,13 @@ const openCodeOverrideDirectory = (
const opencodeDirectory = (context: ClientPathContext): string =>
openCodeOverrideDirectory(context.env) ??
join(xdgConfigHome(context), "opencode");
join(xdgConfigHomeOrDefault(context), "opencode");
/** OpenCode loads global JSON before JSONC, then explicit file and directory overrides. */
const openCodeConfigurationPaths = (
context: ClientPathContext,
): readonly string[] => {
const directory = join(xdgConfigHome(context), "opencode");
const directory = join(xdgConfigHomeOrDefault(context), "opencode");
const overrideDirectory = openCodeOverrideDirectory(context.env);
const paths = [
join(directory, "config.json"),
@@ -565,7 +538,7 @@ const geminiSettings = ({
const devinDirectory = ({ home, platform, env }: ClientPathContext): string =>
platform === "win32"
? join(windowsAppDataRoots(home, env).roaming, "devin")
: join(env.XDG_CONFIG_HOME || join(home, ".config"), "devin");
: join(xdgConfigHomeOrDefault({ home, env }), "devin");
/**
* FreeBuff's own profile directory (its `state.json` root, fixed under