Skip to content

Commit 3f878bc

Browse files
dbrtlydaniel-bartley
authored andcommitted
feat(config-xdg): enable XDG conventions
XDG configurations bring flexibility to files previously written only to `~/.gemini directory`.
1 parent 1e8ae5b commit 3f878bc

27 files changed

+459
-322
lines changed

packages/a2a-server/src/config/extension.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,16 @@
77
// Copied exactly from packages/cli/src/config/extension.ts, last PR #1026
88

99
import {
10-
GEMINI_DIR,
10+
Storage,
1111
type MCPServerConfig,
1212
type ExtensionInstallMetadata,
1313
type GeminiCLIExtension,
1414
} from '@google/gemini-cli-core';
1515
import * as fs from 'node:fs';
1616
import * as path from 'node:path';
17-
import * as os from 'node:os';
1817
import { logger } from '../utils/logger.js';
1918

20-
export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions');
19+
export const EXTENSIONS_DIRECTORY_NAME = path.join('extensions');
2120
export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json';
2221
export const INSTALL_METADATA_FILENAME = '.gemini-extension-install.json';
2322

@@ -39,7 +38,7 @@ interface ExtensionConfig {
3938
export function loadExtensions(workspaceDir: string): GeminiCLIExtension[] {
4039
const allExtensions = [
4140
...loadExtensionsFromDir(workspaceDir),
42-
...loadExtensionsFromDir(os.homedir()),
41+
...loadExtensionsFromDir(Storage.getConfigDir()),
4342
];
4443

4544
const uniqueExtensions: GeminiCLIExtension[] = [];

packages/a2a-server/src/config/settings.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,18 @@
66

77
import * as fs from 'node:fs';
88
import * as path from 'node:path';
9-
import { homedir } from 'node:os';
109

1110
import type { MCPServerConfig } from '@google/gemini-cli-core';
1211
import {
1312
debugLogger,
1413
GEMINI_DIR,
1514
getErrorMessage,
15+
Storage,
1616
type TelemetrySettings,
1717
} from '@google/gemini-cli-core';
1818
import stripJsonComments from 'strip-json-comments';
1919

20-
export const USER_SETTINGS_DIR = path.join(homedir(), GEMINI_DIR);
20+
export const USER_SETTINGS_DIR = path.join(Storage.getConfigDir());
2121
export const USER_SETTINGS_PATH = path.join(USER_SETTINGS_DIR, 'settings.json');
2222

2323
// Reconcile with https://github.com/google-gemini/gemini-cli/blob/b09bc6656080d4d12e1d06734aae2ec33af5c1ed/packages/cli/src/config/settings.ts#L53

packages/cli/src/config/extensions/variables.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,9 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7-
import * as path from 'node:path';
87
import { type VariableSchema, VARIABLE_SCHEMA } from './variableSchema.js';
9-
import { GEMINI_DIR } from '@google/gemini-cli-core';
108

11-
export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions');
9+
export const EXTENSIONS_DIRECTORY_NAME = 'extensions';
1210
export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json';
1311
export const INSTALL_METADATA_FILENAME = '.gemini-extension-install.json';
1412
export const EXTENSION_SETTINGS_FILENAME = '.env';

packages/cli/src/config/settings.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -504,9 +504,13 @@ function findEnvFile(startDir: string): string | null {
504504
const parentDir = path.dirname(currentDir);
505505
if (parentDir === currentDir || !parentDir) {
506506
// check .env under home as fallback, again preferring gemini-specific .env
507-
const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
508-
if (fs.existsSync(homeGeminiEnvPath)) {
509-
return homeGeminiEnvPath;
507+
const userGeminiEnvPath = path.join(
508+
Storage.getConfigDir(),
509+
GEMINI_DIR,
510+
'.env',
511+
);
512+
if (fs.existsSync(userGeminiEnvPath)) {
513+
return userGeminiEnvPath;
510514
}
511515
const homeEnvPath = path.join(homedir(), '.env');
512516
if (fs.existsSync(homeEnvPath)) {

packages/cli/src/config/trustedFolders.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,23 @@
66

77
import * as fs from 'node:fs';
88
import * as path from 'node:path';
9-
import { homedir } from 'node:os';
109
import {
1110
FatalConfigError,
1211
getErrorMessage,
1312
isWithinRoot,
1413
ideContextStore,
15-
GEMINI_DIR,
14+
Storage,
1615
} from '@google/gemini-cli-core';
1716
import type { Settings } from './settings.js';
1817
import stripJsonComments from 'strip-json-comments';
1918

2019
export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json';
2120

22-
export function getUserSettingsDir(): string {
23-
return path.join(homedir(), GEMINI_DIR);
24-
}
25-
2621
export function getTrustedFoldersPath(): string {
2722
if (process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH']) {
2823
return process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH'];
2924
}
30-
return path.join(getUserSettingsDir(), TRUSTED_FOLDERS_FILENAME);
25+
return path.join(Storage.getConfigDir(), TRUSTED_FOLDERS_FILENAME);
3126
}
3227

3328
export enum TrustLevel {

packages/cli/src/ui/components/Notifications.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,12 @@ import { theme } from '../semantic-colors.js';
1212
import { StreamingState } from '../types.js';
1313
import { UpdateNotification } from './UpdateNotification.js';
1414

15-
import { GEMINI_DIR, Storage } from '@google/gemini-cli-core';
15+
import { Storage } from '@google/gemini-cli-core';
1616

1717
import * as fs from 'node:fs/promises';
18-
import os from 'node:os';
1918
import path from 'node:path';
2019

21-
const settingsPath = path.join(os.homedir(), GEMINI_DIR, 'settings.json');
20+
const settingsPath = path.join(Storage.getConfigDir(), 'settings.json');
2221

2322
const screenReaderNudgeFilePath = path.join(
2423
Storage.getGlobalTempDir(),

packages/cli/src/ui/hooks/useExtensionUpdates.test.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import * as os from 'node:os';
1010
import * as path from 'node:path';
1111
import { createExtension } from '../../test-utils/createExtension.js';
1212
import { useExtensionUpdates } from './useExtensionUpdates.js';
13-
import { GEMINI_DIR } from '@google/gemini-cli-core';
13+
import { Storage } from '@google/gemini-cli-core';
1414
import { render } from '../../test-utils/render.js';
1515
import { waitFor } from '../../test-utils/async.js';
1616
import { MessageType } from '../types.js';
@@ -22,6 +22,17 @@ import { ExtensionUpdateState } from '../state/extensions.js';
2222
import { ExtensionManager } from '../../config/extension-manager.js';
2323
import { loadSettings } from '../../config/settings.js';
2424

25+
beforeEach(() => {
26+
vi.stubEnv('XDG_CONFIG_HOME', '');
27+
vi.stubEnv('XDG_CACHE_HOME', '');
28+
vi.stubEnv('XDG_DATA_HOME', '');
29+
vi.stubEnv('XDG_STATE_HOME', '');
30+
});
31+
32+
afterEach(() => {
33+
vi.unstubAllEnvs();
34+
});
35+
2536
vi.mock('os', async (importOriginal) => {
2637
const mockedOs = await importOriginal<typeof os>();
2738
return {
@@ -50,7 +61,7 @@ describe('useExtensionUpdates', () => {
5061
path.join(tempHomeDir, 'gemini-cli-test-workspace-'),
5162
);
5263
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
53-
userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions');
64+
userExtensionsDir = path.join(Storage.getConfigDir(), 'extensions');
5465
fs.mkdirSync(userExtensionsDir, { recursive: true });
5566
vi.mocked(checkForAllExtensionUpdates).mockReset();
5667
vi.mocked(updateExtension).mockReset();

packages/core/src/code_assist/oauth-credential-storage.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,8 @@ import { HybridTokenStorage } from '../mcp/token-storage/hybrid-token-storage.js
99
import { OAUTH_FILE } from '../config/storage.js';
1010
import type { OAuthCredentials } from '../mcp/token-storage/types.js';
1111
import * as path from 'node:path';
12-
import * as os from 'node:os';
1312
import { promises as fs } from 'node:fs';
14-
import { GEMINI_DIR } from '../utils/paths.js';
13+
import { Storage } from '../config/storage.js';
1514
import { coreEvents } from '../utils/events.js';
1615

1716
const KEYCHAIN_SERVICE_NAME = 'gemini-cli-oauth';
@@ -91,7 +90,7 @@ export class OAuthCredentialStorage {
9190
await this.storage.deleteCredentials(MAIN_ACCOUNT_KEY);
9291

9392
// Also try to remove the old file if it exists
94-
const oldFilePath = path.join(os.homedir(), GEMINI_DIR, OAUTH_FILE);
93+
const oldFilePath = Storage.getOAuthCredsPath();
9594
await fs.rm(oldFilePath, { force: true }).catch(() => {});
9695
} catch (error: unknown) {
9796
coreEvents.emitFeedback(
@@ -107,7 +106,7 @@ export class OAuthCredentialStorage {
107106
* Migrate credentials from old file-based storage to keychain
108107
*/
109108
private static async migrateFromFileStorage(): Promise<Credentials | null> {
110-
const oldFilePath = path.join(os.homedir(), GEMINI_DIR, OAUTH_FILE);
109+
const oldFilePath = path.join(Storage.getOAuthCredsPath(), OAUTH_FILE);
111110

112111
let credsJson: string;
113112
try {

packages/core/src/code_assist/oauth2.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ describe('oauth2', () => {
7171
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
7272
);
7373
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
74+
fs.mkdirSync(path.join(tempHomeDir, GEMINI_DIR), { recursive: true });
7475
});
7576
afterEach(() => {
7677
fs.rmSync(tempHomeDir, { recursive: true, force: true });

packages/core/src/config/storage.test.ts

Lines changed: 57 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,65 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7-
import { describe, it, expect, vi } from 'vitest';
8-
import * as os from 'node:os';
7+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
98
import * as path from 'node:path';
109

11-
vi.mock('fs', async (importOriginal) => {
12-
const actual = await importOriginal<typeof import('fs')>();
13-
return {
14-
...actual,
15-
mkdirSync: vi.fn(),
16-
};
10+
const MOCK_GLOBAL_GEMINI_DIR = '/mock/user/home/.gemini';
11+
const MOCK_CACHE_DIR = '/mock/user/home/.cache/gemini';
12+
const MOCK_CONFIG_DIR = '/mock/user/home/.config/gemini';
13+
const MOCK_DATA_DIR = '/mock/user/home/.local/share/gemini';
14+
15+
vi.mock('./storage.js', () => {
16+
class MockStorage {
17+
private readonly targetDir: string;
18+
19+
constructor(targetDir: string) {
20+
this.targetDir = targetDir;
21+
}
22+
23+
static getGlobalGeminiDir = vi.fn(() => MOCK_GLOBAL_GEMINI_DIR);
24+
static getCacheDir = vi.fn(() => MOCK_CACHE_DIR);
25+
static getConfigDir = vi.fn(() => MOCK_CONFIG_DIR);
26+
static getDataDir = vi.fn(() => MOCK_DATA_DIR);
27+
static getMcpOAuthTokensPath = vi.fn(() =>
28+
path.join(MOCK_CONFIG_DIR, 'mcp-oauth-tokens.json'),
29+
);
30+
static getGlobalSettingsPath = vi.fn(() =>
31+
path.join(MOCK_CONFIG_DIR, 'settings.json'),
32+
);
33+
static getUserCommandsDir = vi.fn(() =>
34+
path.join(MOCK_CONFIG_DIR, 'commands'),
35+
);
36+
static getGlobalBinDir = vi.fn(() => path.join(MOCK_CONFIG_DIR, 'bin'));
37+
getGeminiDir = () => path.join(this.targetDir, '.gemini');
38+
getWorkspaceSettingsPath = () =>
39+
path.join(this.getGeminiDir(), 'settings.json');
40+
getProjectCommandsDir = () => path.join(this.getGeminiDir(), 'commands');
41+
}
42+
return { Storage: MockStorage };
1743
});
1844

45+
beforeEach(() => {
46+
vi.stubEnv('XDG_CONFIG_HOME', '');
47+
vi.stubEnv('XDG_CACHE_HOME', '');
48+
vi.stubEnv('XDG_DATA_HOME', '');
49+
vi.stubEnv('XDG_STATE_HOME', '');
50+
});
51+
52+
afterEach(() => {
53+
vi.unstubAllEnvs();
54+
});
55+
56+
vi.mock('../utils/paths.js', () => ({
57+
GEMINI_DIR: '.gemini',
58+
}));
59+
1960
import { Storage } from './storage.js';
2061
import { GEMINI_DIR } from '../utils/paths.js';
2162

2263
describe('Storage – getGlobalSettingsPath', () => {
23-
it('returns path to ~/.gemini/settings.json', () => {
24-
const expected = path.join(os.homedir(), GEMINI_DIR, 'settings.json');
64+
it('returns path to /mock/user/home/.config/gemini/settings.json', () => {
65+
const expected = path.join(MOCK_CONFIG_DIR, 'settings.json');
2566
expect(Storage.getGlobalSettingsPath()).toBe(expected);
2667
});
2768
});
@@ -35,8 +76,8 @@ describe('Storage – additional helpers', () => {
3576
expect(storage.getWorkspaceSettingsPath()).toBe(expected);
3677
});
3778

38-
it('getUserCommandsDir returns ~/.gemini/commands', () => {
39-
const expected = path.join(os.homedir(), GEMINI_DIR, 'commands');
79+
it('getUserCommandsDir returns ~/.config/gemini/commands', () => {
80+
const expected = path.join(MOCK_CONFIG_DIR, 'commands');
4081
expect(Storage.getUserCommandsDir()).toBe(expected);
4182
});
4283

@@ -45,17 +86,13 @@ describe('Storage – additional helpers', () => {
4586
expect(storage.getProjectCommandsDir()).toBe(expected);
4687
});
4788

48-
it('getMcpOAuthTokensPath returns ~/.gemini/mcp-oauth-tokens.json', () => {
49-
const expected = path.join(
50-
os.homedir(),
51-
GEMINI_DIR,
52-
'mcp-oauth-tokens.json',
53-
);
89+
it('getMcpOAuthTokensPath returns ~/.config/gemini/mcp-oauth-tokens.json', () => {
90+
const expected = path.join(MOCK_CONFIG_DIR, 'mcp-oauth-tokens.json');
5491
expect(Storage.getMcpOAuthTokensPath()).toBe(expected);
5592
});
5693

57-
it('getGlobalBinDir returns ~/.gemini/tmp/bin', () => {
58-
const expected = path.join(os.homedir(), GEMINI_DIR, 'tmp', 'bin');
94+
it('getGlobalBinDir returns ~/.config/gemini/bin', () => {
95+
const expected = path.join(MOCK_CONFIG_DIR, 'bin');
5996
expect(Storage.getGlobalBinDir()).toBe(expected);
6097
});
6198
});

0 commit comments

Comments
 (0)