diff --git a/src/application/ClientConfigRoots.test.ts b/src/application/ClientConfigRoots.test.ts new file mode 100644 index 0000000000..03537fc9d9 --- /dev/null +++ b/src/application/ClientConfigRoots.test.ts @@ -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([]); +}); diff --git a/src/application/ClientConfigRoots.ts b/src/application/ClientConfigRoots.ts new file mode 100644 index 0000000000..99f576075a --- /dev/null +++ b/src/application/ClientConfigRoots.ts @@ -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; +} + +/** + * `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): 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): 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"); +}; diff --git a/src/application/McpClientRegistration.test.ts b/src/application/McpClientRegistration.test.ts index e916e55c6d..05ebf88849 100644 --- a/src/application/McpClientRegistration.test.ts +++ b/src/application/McpClientRegistration.test.ts @@ -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( diff --git a/src/application/McpRegistrationKiloLocations.ts b/src/application/McpRegistrationKiloLocations.ts index 0fc1132ce6..27e14d3c97 100644 --- a/src/application/McpRegistrationKiloLocations.ts +++ b/src/application/McpRegistrationKiloLocations.ts @@ -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 => { @@ -162,7 +170,7 @@ export const kiloLocation = async ( markers: [ root, join( - platformPaths(context).vscode, + vscodeUserDirectory(rootContext(context)), "globalStorage", "kilocode.kilo-code", ), diff --git a/src/application/McpRegistrationLocations.ts b/src/application/McpRegistrationLocations.ts index 7a13233987..d1fb18aad3 100644 --- a/src/application/McpRegistrationLocations.ts +++ b/src/application/McpRegistrationLocations.ts @@ -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 => 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 => + // 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 ( diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index f8f00c321a..08456e0ec6 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -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; } -/** 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