Skip to content

Commit 6ba995c

Browse files
committed
feat: add Gemini CLI client adapter with configuration handling, installation detection, and preflight checks, refactorings, adding wizzard to mcp installation
1 parent ccd9a6d commit 6ba995c

105 files changed

Lines changed: 6393 additions & 213 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ node_modules/
1717
.DS_Store
1818
Thumbs.db
1919
desktop.ini
20+
nul
21+
NUL
22+
23+
# Windows temporary files
24+
*.tmp
25+
*.temp
26+
~$*
2027

2128
# Logs
2229
logs/

src/main/__tests__/preload-bridge.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ const EXPECTED_KEYS = [
88
'clientsSync',
99
'clientsSyncAll',
1010
'clientsPreviewConfigImport',
11+
'clientsPreviewSync',
12+
'clientsPreviewSyncAll',
1113
'clientsImportConfigChanges',
1214
'clientsSetManualConfigPath',
1315
'clientsClearManualConfigPath',
@@ -54,6 +56,17 @@ const EXPECTED_KEYS = [
5456
'registrySearch',
5557
'registryPrepareInstall',
5658
'registryInstall',
59+
'installerPrepare',
60+
'installerPreflight',
61+
'installerRun',
62+
'installerCancel',
63+
'installerStatus',
64+
'installerRepair',
65+
'syncListPending',
66+
'syncApplyPending',
67+
'syncAutoPull',
68+
'syncResolveConflict',
69+
'syncPushReview',
5770
'stacksExport',
5871
'stacksImport',
5972
'backupsList',

src/main/clients/__tests__/client-install.service.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,22 @@ describe('ClientInstallService', () => {
144144
expect(spawnMock).not.toHaveBeenCalled()
145145
})
146146

147+
it('installs gemini-cli via npm when npm is available', async () => {
148+
managerAvailability.winget = false
149+
managerAvailability.choco = false
150+
managerAvailability.npm = true
151+
spawnPlanQueue.push({ command: 'npm', exitCode: 0, stdout: 'ok' })
152+
const service = new ClientInstallService()
153+
154+
const result = await service.install('gemini-cli')
155+
156+
expect(result.success).toBe(true)
157+
expect(result.installedWith).toBe('npm')
158+
expect(result.attempts).toHaveLength(1)
159+
expect(result.attempts[0]?.manager).toBe('npm')
160+
expect(spawnMock).toHaveBeenCalledTimes(1)
161+
})
162+
147163
it('emits manager_skipped progress events when managers are unavailable', async () => {
148164
managerAvailability.winget = false
149165
managerAvailability.choco = false
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* @file src/main/clients/__tests__/gemini-cli.adapter.test.ts
3+
*
4+
* @created 10.03.2026
5+
* @modified 10.03.2026
6+
*
7+
* @author Christian Blank <christianblank91@protonmail.com>
8+
* @copyright 2026
9+
*
10+
* @description Unit tests for the Gemini CLI client adapter.
11+
*/
12+
13+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
14+
import { mkdirSync, writeFileSync, rmSync, readFileSync } from 'fs'
15+
import { join } from 'path'
16+
import { tmpdir } from 'os'
17+
import { geminiCliAdapter } from '../gemini-cli.adapter'
18+
19+
const makeTmpDir = (): string => {
20+
const dir = join(tmpdir(), `gemini-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
21+
mkdirSync(dir, { recursive: true })
22+
return dir
23+
}
24+
25+
describe('geminiCliAdapter', () => {
26+
let tmpDir: string
27+
let originalUserProfile: string | undefined
28+
let originalPath: string | undefined
29+
let originalPathExt: string | undefined
30+
let originalAppData: string | undefined
31+
let originalLocalAppData: string | undefined
32+
33+
beforeEach(() => {
34+
tmpDir = makeTmpDir()
35+
36+
originalUserProfile = process.env['USERPROFILE']
37+
originalPath = process.env['PATH']
38+
originalPathExt = process.env['PATHEXT']
39+
originalAppData = process.env['APPDATA']
40+
originalLocalAppData = process.env['LOCALAPPDATA']
41+
42+
process.env['USERPROFILE'] = join(tmpDir, 'user')
43+
process.env['PATH'] = join(tmpDir, 'bin')
44+
process.env['PATHEXT'] = '.EXE;.CMD;.BAT'
45+
process.env['APPDATA'] = join(tmpDir, 'appdata')
46+
process.env['LOCALAPPDATA'] = join(tmpDir, 'localappdata')
47+
})
48+
49+
afterEach(() => {
50+
process.env['USERPROFILE'] = originalUserProfile
51+
process.env['PATH'] = originalPath
52+
process.env['PATHEXT'] = originalPathExt
53+
process.env['APPDATA'] = originalAppData
54+
process.env['LOCALAPPDATA'] = originalLocalAppData
55+
rmSync(tmpDir, { recursive: true, force: true })
56+
})
57+
58+
it('reports not installed when config and executable are absent', async () => {
59+
const result = await geminiCliAdapter.detect()
60+
expect(result.installed).toBe(false)
61+
expect(result.configPaths).toHaveLength(0)
62+
expect(result.serverCount).toBe(0)
63+
})
64+
65+
it('detects installed when gemini executable exists on PATH', async () => {
66+
const binDir = process.env['PATH'] ?? ''
67+
mkdirSync(binDir, { recursive: true })
68+
writeFileSync(join(binDir, 'gemini.cmd'), '')
69+
70+
const result = await geminiCliAdapter.detect()
71+
expect(result.installed).toBe(true)
72+
expect(result.configPaths).toHaveLength(0)
73+
expect(result.serverCount).toBe(0)
74+
})
75+
76+
it('detects installed state from .gemini/settings.json', async () => {
77+
const geminiDir = join(process.env['USERPROFILE'] ?? '', '.gemini')
78+
mkdirSync(geminiDir, { recursive: true })
79+
writeFileSync(
80+
join(geminiDir, 'settings.json'),
81+
JSON.stringify({ mcpServers: { tool: { command: 'npx', args: [] } } }),
82+
)
83+
84+
const result = await geminiCliAdapter.detect()
85+
expect(result.installed).toBe(true)
86+
expect(result.configPaths).toHaveLength(1)
87+
expect(result.serverCount).toBe(1)
88+
})
89+
90+
it('reads mcpServers correctly', async () => {
91+
const geminiDir = join(tmpDir, '.gemini')
92+
const configFile = join(geminiDir, 'settings.json')
93+
mkdirSync(geminiDir, { recursive: true })
94+
writeFileSync(
95+
configFile,
96+
JSON.stringify({ mcpServers: { myTool: { command: 'node', args: ['server.js'] } } }),
97+
)
98+
99+
const servers = await geminiCliAdapter.read(configFile)
100+
expect(servers['myTool']?.command).toBe('node')
101+
})
102+
103+
it('writes servers and preserves existing top-level keys', async () => {
104+
const geminiDir = join(tmpDir, '.gemini')
105+
const configFile = join(geminiDir, 'settings.json')
106+
mkdirSync(geminiDir, { recursive: true })
107+
writeFileSync(configFile, JSON.stringify({ model: 'gemini-2.5-pro' }))
108+
109+
await geminiCliAdapter.write(configFile, { newServer: { command: 'python', args: [] } })
110+
const raw = JSON.parse(readFileSync(configFile, 'utf-8')) as {
111+
model?: string
112+
mcpServers?: Record<string, unknown>
113+
}
114+
115+
expect(raw.model).toBe('gemini-2.5-pro')
116+
expect(raw.mcpServers?.['newServer']).toBeDefined()
117+
})
118+
119+
it('validate reports malformed json', async () => {
120+
const geminiDir = join(tmpDir, '.gemini')
121+
const configFile = join(geminiDir, 'settings.json')
122+
mkdirSync(geminiDir, { recursive: true })
123+
writeFileSync(configFile, '{ bad json')
124+
125+
const validation = await geminiCliAdapter.validate(configFile)
126+
expect(validation.valid).toBe(false)
127+
expect(validation.errors[0]).toContain('JSON parse error')
128+
})
129+
})

src/main/clients/claude-desktop.adapter.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,14 @@ export const claudeDesktopAdapter: ClientAdapter = {
128128
read(configPath: string): Promise<McpServerMap> {
129129
if (!existsSync(configPath)) return Promise.resolve({})
130130

131-
const raw = JSON.parse(readFileSync(configPath, 'utf-8')) as ClaudeConfig
132-
return Promise.resolve((raw.mcpServers ?? {}) as McpServerMap)
131+
try {
132+
const raw = JSON.parse(readFileSync(configPath, 'utf-8')) as ClaudeConfig
133+
return Promise.resolve((raw.mcpServers ?? {}) as McpServerMap)
134+
} catch (err) {
135+
const message = err instanceof Error ? err.message : String(err)
136+
log.warn(`[claude-desktop] failed to parse config ${configPath}: ${message}`)
137+
return Promise.resolve({})
138+
}
133139
},
134140

135141
write(configPath: string, servers: McpServerMap): Promise<void> {

src/main/clients/client-install.service.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,16 @@ const INSTALL_DEFINITIONS: Readonly<Record<ClientId, ClientInstallDefinition>> =
220220
attempts: [],
221221
manualOnly: true,
222222
},
223+
'gemini-cli': {
224+
docsUrl: 'https://github.com/google-gemini/gemini-cli',
225+
attempts: [
226+
{
227+
manager: 'npm',
228+
command: 'npm',
229+
args: ['install', '--global', '@google/gemini-cli', '--yes', '--silent'],
230+
},
231+
],
232+
},
223233
'codex-cli': {
224234
docsUrl: 'https://github.com/openai/codex',
225235
attempts: [
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* @file src/main/clients/gemini-cli.adapter.ts
3+
*
4+
* @created 10.03.2026
5+
* @modified 10.03.2026
6+
*
7+
* @author Christian Blank <christianblank91@protonmail.com>
8+
* @copyright 2026
9+
*
10+
* @description Client adapter for Gemini CLI. Config lives at
11+
* `%USERPROFILE%\.gemini\settings.json` on Windows. MCP server definitions
12+
* are stored under top-level `mcpServers`.
13+
*/
14+
15+
import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from 'fs'
16+
import { join, dirname } from 'path'
17+
import log from 'electron-log'
18+
import type { ClientDetectionResult, McpServerMap, ValidationResult } from '@shared/types'
19+
import type { ClientAdapter } from './types'
20+
import { hasWindowsCommandOnPath } from './windows-detection.util'
21+
22+
interface GeminiConfig {
23+
mcpServers?: Record<string, unknown>
24+
[key: string]: unknown
25+
}
26+
27+
const configPath = (): string => join(process.env['USERPROFILE'] ?? '', '.gemini', 'settings.json')
28+
29+
const isGeminiCliInstalled = (): boolean => {
30+
if (process.platform !== 'win32') return false
31+
32+
const appData = process.env['APPDATA'] ?? ''
33+
const localAppData = process.env['LOCALAPPDATA'] ?? ''
34+
const candidates = [
35+
join(appData, 'npm', 'gemini.cmd'),
36+
join(appData, 'npm', 'gemini.exe'),
37+
join(localAppData, 'Microsoft', 'WindowsApps', 'gemini.exe'),
38+
join(localAppData, 'Microsoft', 'WindowsApps', 'gemini.cmd'),
39+
]
40+
41+
if (candidates.some((path) => existsSync(path))) {
42+
return true
43+
}
44+
45+
return hasWindowsCommandOnPath(['gemini'])
46+
}
47+
48+
export const geminiCliAdapter: ClientAdapter = {
49+
id: 'gemini-cli',
50+
displayName: 'Gemini CLI',
51+
schemaKey: 'mcpServers',
52+
53+
detect(): Promise<ClientDetectionResult> {
54+
const path = configPath()
55+
const hasConfig = existsSync(path)
56+
const installed = hasConfig || isGeminiCliInstalled()
57+
let serverCount = 0
58+
59+
if (hasConfig) {
60+
try {
61+
const raw = JSON.parse(readFileSync(path, 'utf-8')) as GeminiConfig
62+
serverCount = Object.keys(raw.mcpServers ?? {}).length
63+
} catch {
64+
serverCount = 0
65+
}
66+
}
67+
68+
log.debug(`[gemini-cli] detect: installed=${installed}, servers=${serverCount}`)
69+
70+
return Promise.resolve({
71+
installed,
72+
configPaths: hasConfig ? [path] : [],
73+
serverCount,
74+
})
75+
},
76+
77+
read(configPath: string): Promise<McpServerMap> {
78+
if (!existsSync(configPath)) return Promise.resolve({})
79+
const raw = JSON.parse(readFileSync(configPath, 'utf-8')) as GeminiConfig
80+
return Promise.resolve((raw.mcpServers ?? {}) as McpServerMap)
81+
},
82+
83+
write(configPath: string, servers: McpServerMap): Promise<void> {
84+
mkdirSync(dirname(configPath), { recursive: true })
85+
86+
let existing: GeminiConfig = {}
87+
if (existsSync(configPath)) {
88+
existing = JSON.parse(readFileSync(configPath, 'utf-8')) as GeminiConfig
89+
}
90+
91+
const merged: GeminiConfig = { ...existing, mcpServers: servers }
92+
const tmpPath = `${configPath}.aidrelay.tmp`
93+
writeFileSync(tmpPath, JSON.stringify(merged, null, 2), 'utf-8')
94+
renameSync(tmpPath, configPath)
95+
96+
log.info(`[gemini-cli] wrote ${Object.keys(servers).length} server(s) to ${configPath}`)
97+
return Promise.resolve()
98+
},
99+
100+
validate(configPath: string): Promise<ValidationResult> {
101+
if (!existsSync(configPath)) {
102+
return Promise.resolve({ valid: false, errors: [`Config file not found: ${configPath}`] })
103+
}
104+
try {
105+
const raw = JSON.parse(readFileSync(configPath, 'utf-8')) as GeminiConfig
106+
if (raw.mcpServers !== undefined && typeof raw.mcpServers !== 'object') {
107+
return Promise.resolve({ valid: false, errors: ['mcpServers must be an object'] })
108+
}
109+
return Promise.resolve({ valid: true, errors: [] })
110+
} catch (err) {
111+
const message = err instanceof Error ? err.message : String(err)
112+
return Promise.resolve({ valid: false, errors: [`JSON parse error: ${message}`] })
113+
}
114+
},
115+
}

src/main/clients/registry.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { vscodeInsidersAdapter } from './vscode-insiders.adapter'
2121
import { windsurfAdapter } from './windsurf.adapter'
2222
import { zedAdapter } from './zed.adapter'
2323
import { jetbrainsAdapter } from './jetbrains.adapter'
24+
import { geminiCliAdapter } from './gemini-cli.adapter'
2425
import { codexCliAdapter } from './codex-cli.adapter'
2526
import { codexGuiAdapter } from './codex-gui.adapter'
2627
import { opencodeAdapter } from './opencode.adapter'
@@ -39,6 +40,7 @@ export const ADAPTERS: Readonly<Map<ClientId, ClientAdapter>> = new Map<ClientId
3940
['windsurf', windsurfAdapter],
4041
['zed', zedAdapter],
4142
['jetbrains', jetbrainsAdapter],
43+
['gemini-cli', geminiCliAdapter],
4244
['codex-cli', codexCliAdapter],
4345
['codex-gui', codexGuiAdapter],
4446
['opencode', opencodeAdapter],
@@ -58,6 +60,7 @@ export const ADAPTER_IDS: readonly ClientId[] = [
5860
'windsurf',
5961
'zed',
6062
'jetbrains',
63+
'gemini-cli',
6164
'codex-cli',
6265
'codex-gui',
6366
'opencode',

src/main/clients/vscode-insiders.adapter.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,14 @@ export const vscodeInsidersAdapter: ClientAdapter = {
8585
read(configPath: string): Promise<McpServerMap> {
8686
if (!existsSync(configPath)) return Promise.resolve({})
8787

88-
const raw = JSON.parse(readFileSync(configPath, 'utf-8')) as VsCodeInsidersConfig
89-
return Promise.resolve((raw.servers ?? {}) as McpServerMap)
88+
try {
89+
const raw = JSON.parse(readFileSync(configPath, 'utf-8')) as VsCodeInsidersConfig
90+
return Promise.resolve((raw.servers ?? {}) as McpServerMap)
91+
} catch (err) {
92+
const message = err instanceof Error ? err.message : String(err)
93+
log.warn(`[vscode-insiders] failed to parse config ${configPath}: ${message}`)
94+
return Promise.resolve({})
95+
}
9096
},
9197

9298
write(configPath: string, servers: McpServerMap): Promise<void> {

0 commit comments

Comments
 (0)