diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 1eddd1da..9bc7dfec 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -16,6 +16,7 @@ import type { OpenCodeConfig, CodexConfig, EffortLevel, + GeminiConfig, } from './types.js'; /** @@ -64,6 +65,7 @@ export interface CreateSessionOptions { allowedTools?: string; openCodeConfig?: OpenCodeConfig; codexConfig?: CodexConfig; + geminiConfig?: GeminiConfig; /** When restoring after reboot, resume a previous Claude conversation by its session ID */ resumeSessionId?: string; /** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */ @@ -83,6 +85,7 @@ export interface RespawnPaneOptions { allowedTools?: string; openCodeConfig?: OpenCodeConfig; codexConfig?: CodexConfig; + geminiConfig?: GeminiConfig; /** Resume a previous Claude conversation when respawning */ resumeSessionId?: string; /** Extra env vars exported before launching the CLI (preserved across respawns). */ diff --git a/src/session.ts b/src/session.ts index a6281069..3b45835d 100644 --- a/src/session.ts +++ b/src/session.ts @@ -48,6 +48,7 @@ import { type OpenCodeConfig, type CodexConfig, type EffortLevel, + type GeminiConfig, } from './types.js'; import type { TerminalMultiplexer, MuxSession } from './mux-interface.js'; import { TaskTracker, type BackgroundTask } from './task-tracker.js'; @@ -133,7 +134,22 @@ const NEWLINE_SPLIT_PATTERN = /\r?\n/; /** True for external-CLI run modes (non-Claude) that use their own TUI and output format. */ export function isExternalCliMode(mode: SessionMode): boolean { - return mode === 'opencode' || mode === 'codex'; + return mode === 'opencode' || mode === 'codex' || mode === 'gemini'; +} + +function getModeLabel(mode: SessionMode): string { + switch (mode) { + case 'opencode': + return 'OpenCode'; + case 'codex': + return 'Codex'; + case 'gemini': + return 'Gemini'; + case 'shell': + return 'Shell'; + case 'claude': + return 'Claude'; + } } /** @@ -351,6 +367,8 @@ export class Session extends EventEmitter { private _openCodeConfig: OpenCodeConfig | undefined; // Codex configuration (only for mode === 'codex') private _codexConfig: CodexConfig | undefined; + // Gemini configuration (only for mode === 'gemini') + private _geminiConfig: GeminiConfig | undefined; private _resumeSessionId: string | undefined; // Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux @@ -421,6 +439,8 @@ export class Session extends EventEmitter { openCodeConfig?: OpenCodeConfig; /** Codex configuration (only for mode === 'codex') */ codexConfig?: CodexConfig; + /** Gemini configuration (only for mode === 'gemini') */ + geminiConfig?: GeminiConfig; /** Resume a previous Claude conversation (used after server reboot) */ resumeSessionId?: string; /** Extra env vars exported to the CLI at spawn time (no disk persistence) */ @@ -481,6 +501,11 @@ export class Session extends EventEmitter { this._codexConfig = config.codexConfig; } + // Apply Gemini configuration + if (config.geminiConfig) { + this._geminiConfig = config.geminiConfig; + } + // Apply env overrides (exported at spawn, not persisted to disk). // Legacy migration: pre-0.7.2 carried effort as the CLAUDE_CODE_EFFORT_LEVEL env var, // which hard-locks /effort switching. Extract it into _effort (--settings soft default) @@ -985,6 +1010,7 @@ export class Session extends EventEmitter { cliLatestVersion: this._cliLatestVersion || undefined, openCodeConfig: this._openCodeConfig, codexConfig: this._codexConfig, + geminiConfig: this._geminiConfig, resumeSessionId: this._resumeSessionId, effort: this._effort, attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined, @@ -1224,7 +1250,7 @@ export class Session extends EventEmitter { this._resetBuffers(); - const modeLabel = this.mode === 'opencode' ? 'OpenCode' : this.mode === 'codex' ? 'Codex' : 'Claude'; + const modeLabel = getModeLabel(this.mode); console.log( `[Session] Starting interactive ${modeLabel} session` + (this._useMux ? ` (with ${this._mux!.backend})` : '') ); @@ -1243,6 +1269,7 @@ export class Session extends EventEmitter { allowedTools: this._allowedTools, openCodeConfig: this._openCodeConfig, codexConfig: this._codexConfig, + geminiConfig: this._geminiConfig, resumeSessionId: this._resumeSessionId, envOverrides: this._envOverrides, effort: this._effort, @@ -1258,6 +1285,7 @@ export class Session extends EventEmitter { allowedTools: this._allowedTools, openCodeConfig: this._openCodeConfig, codexConfig: this._codexConfig, + geminiConfig: this._geminiConfig, resumeSessionId: this._resumeSessionId, envOverrides: this._envOverrides, effort: this._effort, @@ -1331,6 +1359,10 @@ export class Session extends EventEmitter { if (this.mode === 'codex') { throw new Error('Codex sessions require tmux. Direct PTY fallback is not supported.'); } + // Gemini sessions require tmux for Gemini/Google auth env injection via setenv + if (this.mode === 'gemini') { + throw new Error('Gemini sessions require tmux. Direct PTY fallback is not supported.'); + } try { // Pass --session-id to use the SAME ID as the Codeman session // This ensures subagents can be directly matched to the correct tab diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 74987265..6e288055 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -41,9 +41,17 @@ import { type OpenCodeConfig, type CodexConfig, type EffortLevel, + type GeminiConfig, } from './types.js'; import { buildEffortCliArgs } from './session-cli-builder.js'; -import { wrapWithNice, SAFE_PATH_PATTERN, findClaudeDir, resolveOpenCodeDir, resolveCodexDir } from './utils/index.js'; +import { + wrapWithNice, + SAFE_PATH_PATTERN, + findClaudeDir, + resolveOpenCodeDir, + resolveCodexDir, + resolveGeminiDir, +} from './utils/index.js'; import type { TerminalMultiplexer, MuxSession, @@ -571,6 +579,35 @@ export function buildCodexCommand(config?: CodexConfig): string { return parts.join(' '); } +/** + * Build the Gemini CLI command with appropriate flags. + * + * `--skip-trust` avoids a first-run workspace trust prompt inside Codeman. + * Approval mode defaults to `yolo` for parity with Codeman's Claude default + * of `--dangerously-skip-permissions`; users can override it later through + * Gemini config once Codeman exposes richer Gemini settings. + */ +function buildGeminiCommand(config?: GeminiConfig): string { + const parts = ['gemini', '--skip-trust']; + + const approvalMode = config?.approvalMode || 'yolo'; + if (['default', 'auto_edit', 'yolo', 'plan'].includes(approvalMode)) { + parts.push('--approval-mode', approvalMode); + } + + if (config?.model) { + const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined; + if (safeModel) parts.push('--model', safeModel); + } + + if (config?.resumeSession) { + const safeId = /^[a-zA-Z0-9._-]+$/.test(config.resumeSession) ? config.resumeSession : undefined; + if (safeId) parts.push('--resume', safeId); + } + + return parts.join(' '); +} + /** * Build the spawn command for any session mode. * Shared by createSession() and respawnPane() to avoid duplication. @@ -597,6 +634,7 @@ function buildSpawnCommand(options: { allowedTools?: string; openCodeConfig?: OpenCodeConfig; codexConfig?: CodexConfig; + geminiConfig?: GeminiConfig; resumeSessionId?: string; effort?: EffortLevel; }): string { @@ -624,6 +662,9 @@ function buildSpawnCommand(options: { if (options.mode === 'codex') { return buildCodexCommand(options.codexConfig); } + if (options.mode === 'gemini') { + return buildGeminiCommand(options.geminiConfig); + } return '$SHELL'; } @@ -674,6 +715,38 @@ function setCodexEnvVars(tmuxCmd: string, muxName: string): void { } } +/** + * Set sensitive environment variables for Gemini on a tmux session via setenv. + * Gemini Pro/Ultra users usually authenticate via cached Google login; these + * variables cover API-key and Vertex AI paths without putting secrets in ps. + */ +function setGeminiEnvVars(muxName: string): void { + const sensitiveVars = [ + 'GEMINI_API_KEY', + 'GEMINI_MODEL', + 'GOOGLE_API_KEY', + 'GOOGLE_CLOUD_PROJECT', + 'GOOGLE_CLOUD_LOCATION', + 'GOOGLE_APPLICATION_CREDENTIALS', + 'GOOGLE_GENAI_USE_VERTEXAI', + ]; + for (const key of sensitiveVars) { + const val = process.env[key]; + if (val) { + const escaped = val.replace(/'/g, "'\\''"); + try { + execSync(`tmux setenv -t '${muxName}' ${key} '${escaped}'`, { + encoding: 'utf8', + timeout: EXEC_TIMEOUT_MS, + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch { + /* Non-critical — key may not be needed */ + } + } + } +} + /** * Set OPENCODE_CONFIG_CONTENT on a tmux session via setenv. * Uses tmux setenv to avoid shell metacharacter injection from user-supplied JSON. @@ -930,6 +1003,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const dir = resolveCodexDir(); return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; } + if (mode === 'gemini') { + const dir = resolveGeminiDir(); + return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; + } return { pathExport: '', dir: null }; } @@ -953,6 +1030,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { setCodexEnvVars(this.tmux(), muxName); } + /** + * Configure Gemini-specific environment on a tmux session. + */ + private _configureGemini(muxName: string): void { + setGeminiEnvVars(muxName); + } + /** * Creates a new tmux session wrapping Claude CLI or a shell. * In test mode: creates an in-memory session only (no real tmux session). @@ -969,6 +1053,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { allowedTools, openCodeConfig, codexConfig, + geminiConfig, resumeSessionId, envOverrides, effort, @@ -1007,6 +1092,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { if (mode === 'opencode' && !cliDir) { throw new Error('OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'); } + if (mode === 'codex' && !cliDir) { + throw new Error('Codex CLI not found. Install with: npm install -g @openai/codex'); + } + if (mode === 'gemini' && !cliDir) { + throw new Error('Gemini CLI not found. Install with: npm install -g @google/gemini-cli'); + } const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && '); @@ -1018,6 +1109,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { allowedTools, openCodeConfig, codexConfig, + geminiConfig, resumeSessionId, effort, }); @@ -1067,6 +1159,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } else if (mode === 'codex') { this._configureCodex(muxName); } + // For Gemini: set Gemini/Google auth env vars via tmux setenv + if (mode === 'gemini') { + this._configureGemini(muxName); + } // Apply user-supplied env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL) via tmux setenv // so secret values stay off the bash command line. Must run before respawn-pane. @@ -1224,6 +1320,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { allowedTools, openCodeConfig, codexConfig, + geminiConfig, resumeSessionId, envOverrides, effort, @@ -1247,6 +1344,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { allowedTools, openCodeConfig, codexConfig, + geminiConfig, resumeSessionId, effort, }); @@ -1261,6 +1359,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } else if (mode === 'codex') { this._configureCodex(muxName); } + // For Gemini: set Gemini/Google auth env vars via tmux setenv before respawn + if (mode === 'gemini') { + this._configureGemini(muxName); + } // Re-apply user env overrides before respawn so the new shell inherits them. this.applyEnvOverrides(muxName, envOverrides); diff --git a/src/types/session.ts b/src/types/session.ts index 4cf742d9..460f6e67 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -8,10 +8,12 @@ * - SessionConfig — creation-time config (id, workingDir, createdAt) * - SessionOutput — captured stdout/stderr/exitCode * - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error' - * - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' (which CLI backend) + * - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' (which CLI backend) * - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'normal' | 'allowedTools') * - SessionColor — visual differentiation color * - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession) + * - CodexConfig — Codex (OpenAI CLI)-specific settings (model, resumeSessionId) + * - GeminiConfig — Gemini CLI-specific settings (model, approvalMode, resumeSession) * * Cross-domain relationships: * - SessionState.respawnConfig embeds RespawnConfig (respawn domain) @@ -39,7 +41,7 @@ export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error'; export type ClaudeMode = 'dangerously-skip-permissions' | 'normal' | 'allowedTools'; /** Session mode: which CLI backend a session runs */ -export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex'; +export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini'; /** * Valid Claude CLI effort levels (claude >= 2.1.154). @@ -85,6 +87,16 @@ export interface CodexConfig { renderMode?: CodexRenderMode; } +/** Gemini CLI session configuration */ +export interface GeminiConfig { + /** Model identifier (e.g., "gemini-2.5-pro"). Passed via --model. */ + model?: string; + /** Gemini approval mode for tool calls. */ + approvalMode?: 'default' | 'auto_edit' | 'yolo' | 'plan'; + /** Resume a previous Gemini session ("latest", index, or session id). */ + resumeSession?: string; +} + /** * Configuration for creating a new session */ @@ -214,6 +226,8 @@ export interface SessionState { openCodeConfig?: OpenCodeConfig; /** Codex-specific configuration (only for mode === 'codex') */ codexConfig?: CodexConfig; + /** Gemini-specific configuration (only for mode === 'gemini') */ + geminiConfig?: GeminiConfig; /** Claude conversation session ID to resume after reboot (set by restore script) */ resumeSessionId?: string; /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ diff --git a/src/utils/gemini-cli-resolver.ts b/src/utils/gemini-cli-resolver.ts new file mode 100644 index 00000000..6f0b5099 --- /dev/null +++ b/src/utils/gemini-cli-resolver.ts @@ -0,0 +1,67 @@ +/** + * @fileoverview Resolve the Gemini CLI binary across common install paths. + * + * Mirrors codex-cli-resolver.ts and opencode-cli-resolver.ts. Finds the + * `gemini` binary and provides an augmented PATH directory for tmux sessions. + * + * @module utils/gemini-cli-resolver + */ + +import { execSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; +import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; + +/** Common directories where the Gemini CLI binary may be installed */ +const GEMINI_SEARCH_DIRS = [ + join(homedir(), '.gemini', 'bin'), + join(homedir(), '.local', 'bin'), + '/usr/local/bin', + join(homedir(), '.bun', 'bin'), + join(homedir(), '.npm-global', 'bin'), + join(homedir(), 'bin'), +]; + +/** Cached directory containing the gemini binary (empty string = searched but not found) */ +let _geminiDir: string | null = null; + +/** + * Finds the directory containing the `gemini` binary. + * Checks `which gemini` first, then falls back to common install locations. + * + * @returns Directory path, or null if not found + */ +export function resolveGeminiDir(): string | null { + if (_geminiDir !== null) return _geminiDir || null; + + try { + const result = execSync('which gemini', { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + }).trim(); + if (result && existsSync(result)) { + _geminiDir = dirname(result); + return _geminiDir; + } + } catch { + // Gemini not in PATH, will check common locations + } + + for (const dir of GEMINI_SEARCH_DIRS) { + if (existsSync(join(dir, 'gemini'))) { + _geminiDir = dir; + return _geminiDir; + } + } + + _geminiDir = ''; + return null; +} + +/** + * Check if Gemini CLI is available on the system. + */ +export function isGeminiAvailable(): boolean { + return resolveGeminiDir() !== null; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index d6e76fc5..5d7f5219 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -29,3 +29,4 @@ export { wrapWithNice } from './nice-wrapper.js'; export { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.js'; export { resolveOpenCodeDir } from './opencode-cli-resolver.js'; export { resolveCodexDir, isCodexAvailable } from './codex-cli-resolver.js'; +export { resolveGeminiDir } from './gemini-cli-resolver.js'; diff --git a/src/web/public/index.html b/src/web/public/index.html index 4c4c7734..e5cb8048 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -305,6 +305,10 @@

Codeman

Run OpenCode +
@@ -400,6 +404,9 @@

Resume Conversation

+
Recent Sessions
diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index e429ee0f..6b8ef7e7 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -777,6 +777,20 @@ html.mobile-init .file-browser-panel { border-color: rgba(16, 185, 129, 0.5); } + /* Gemini mode colors on mobile */ + .btn-toolbar.btn-run.mode-gemini, + .btn-toolbar.btn-run-gear.mode-gemini { + background: #10243f; + border-color: rgba(96, 165, 250, 0.3); + color: #dbeafe; + } + + .btn-toolbar.btn-run.mode-gemini:active, + .btn-toolbar.btn-run-gear.mode-gemini:active { + background: #174ea6; + border-color: rgba(96, 165, 250, 0.5); + } + /* Run mode dropdown menu — positioned above toolbar on mobile */ .run-mode-menu { bottom: 100%; diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 2a2e07f7..33141854 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -1,5 +1,5 @@ /** - * @fileoverview Quick start (case loading, session spawning for Claude/Shell/OpenCode), + * @fileoverview Quick start (case loading, session spawning for Claude/Shell/OpenCode/Codex/Gemini), * session options modal (per-session settings, color picker, rename), * session options tabs (Ralph config tab), case settings (CRUD, links), * create case modal, and mobile case picker. @@ -151,7 +151,7 @@ Object.assign(CodemanApp.prototype, { return this.run(); }, - /** Run using the selected mode (Claude Code, OpenCode, or Codex) */ + /** Run using the selected mode (Claude Code, OpenCode, Codex, or Gemini) */ async run() { const mode = this._runMode || 'claude'; if (mode === 'opencode') { @@ -160,6 +160,9 @@ Object.assign(CodemanApp.prototype, { if (mode === 'codex') { return this.runCodex(); } + if (mode === 'gemini') { + return this.runGemini(); + } return this.runClaude(); }, @@ -257,7 +260,7 @@ Object.assign(CodemanApp.prototype, { gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`; } if (label) { - label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : 'Run'; + label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : 'Run'; } }, @@ -640,6 +643,47 @@ Object.assign(CodemanApp.prototype, { } }, + async runGemini() { + const caseName = document.getElementById('quickStartCase').value || 'testcase'; + + this.terminal.clear(); + this.terminal.writeln(`\x1b[1;32m Starting Gemini session in ${caseName}...\x1b[0m`); + this.terminal.writeln(''); + this.terminal.focus(); + + try { + const statusRes = await fetch('/api/gemini/status'); + const status = await statusRes.json(); + if (!status.available) { + this.terminal.writeln('\x1b[1;31m Gemini CLI not found.\x1b[0m'); + this.terminal.writeln('\x1b[90m Install with: npm install -g @google/gemini-cli\x1b[0m'); + return; + } + + const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage()); + const res = await fetch('/api/quick-start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + caseName, + mode: 'gemini', + geminiConfig: { approvalMode: 'yolo' }, + ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), + }) + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Failed to start Gemini'); + + if (data.sessionId) { + await this.selectSession(data.sessionId); + } + + this.terminal.focus(); + } catch (err) { + this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`); + } + }, + // ═══════════════════════════════════════════════════════════════ // Session Options Modal @@ -651,8 +695,9 @@ Object.assign(CodemanApp.prototype, { this.editingSessionId = sessionId; - // Reset to an appropriate tab — Summary for OpenCode (Respawn/Ralph are Claude-only) - this.switchOptionsTab(session.mode === 'opencode' || session.mode === 'codex' ? 'summary' : 'respawn'); + // Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only) + const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini'; + this.switchOptionsTab(isAltMode ? 'summary' : 'respawn'); // Update respawn status display and buttons const respawnStatus = document.getElementById('sessionRespawnStatus'); @@ -680,10 +725,10 @@ Object.assign(CodemanApp.prototype, { respawnSection.style.display = 'none'; } - // Hide Claude-specific options for OpenCode sessions - const isOpenCode = session.mode === 'opencode' || session.mode === 'codex'; + // Hide Claude-specific options for external CLI sessions + const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini'; const claudeOnlyEls = document.querySelectorAll('[data-claude-only]'); - claudeOnlyEls.forEach(el => { el.style.display = isOpenCode ? 'none' : ''; }); + claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; }); // Reset duration presets to default (unlimited) this.selectDurationPreset(''); @@ -731,21 +776,21 @@ Object.assign(CodemanApp.prototype, { document.getElementById('respawnPresetSelect').value = ''; document.getElementById('presetDescriptionHint').textContent = ''; - // Hide Ralph/Todo tab and Respawn tab for opencode sessions (not supported) + // Hide Ralph/Todo tab and Respawn tab for external CLI sessions (not supported) const ralphTabBtn = document.querySelector('#sessionOptionsModal .modal-tab-btn[data-tab="ralph"]'); const respawnTabBtn = document.querySelector('#sessionOptionsModal .modal-tab-btn[data-tab="respawn"]'); - if (isOpenCode) { + if (isExternalCli) { if (ralphTabBtn) ralphTabBtn.style.display = 'none'; if (respawnTabBtn) respawnTabBtn.style.display = 'none'; - // Default to Context tab for opencode sessions since Respawn is hidden + // Default to Context tab for external CLI sessions since Respawn is hidden this.switchOptionsTab('context'); } else { if (ralphTabBtn) ralphTabBtn.style.display = ''; if (respawnTabBtn) respawnTabBtn.style.display = ''; } - // Populate Ralph Wiggum form with current session values (skip for opencode) - if (!isOpenCode) { + // Populate Ralph Wiggum form with current session values (skip for external CLI sessions) + if (!isExternalCli) { const ralphState = this.ralphStates.get(sessionId); this.populateRalphForm({ enabled: ralphState?.loop?.enabled ?? session.ralphLoop?.enabled ?? false, @@ -1579,6 +1624,7 @@ Object.defineProperty(CodemanApp.prototype, 'runMode', { return this._runMode || 'claude'; }, set(mode) { - this._runMode = mode === 'opencode' || mode === 'codex' || mode === 'claude' ? mode : 'claude'; + this._runMode = + mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'claude' ? mode : 'claude'; }, }); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index eeba9e9b..5e45121a 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2362,6 +2362,21 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { transform: translateY(-1px); } +.welcome-btn-gemini { + background: linear-gradient(135deg, #10243f 0%, #174ea6 55%, #4f46e5 100%); + border-color: rgba(96, 165, 250, 0.4); + color: #dbeafe; + box-shadow: 0 2px 8px rgba(96, 165, 250, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.welcome-btn-gemini:hover { + background: linear-gradient(135deg, #17345f 0%, #2563eb 55%, #6366f1 100%); + box-shadow: 0 4px 20px rgba(96, 165, 250, 0.3), 0 0 40px rgba(99, 102, 241, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.08); + border-color: rgba(147, 197, 253, 0.5); + color: #eff6ff; + transform: translateY(-1px); +} + .welcome-btn-tunnel { background: linear-gradient(135deg, #221538 0%, #3b1a7a 50%, #6d28d9 100%); border-color: rgba(124, 58, 237, 0.4); @@ -2910,6 +2925,22 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { color: #e9d5ff; } +/* Gemini mode colors */ +.btn-toolbar.btn-run.mode-gemini, +.btn-toolbar.btn-run-gear.mode-gemini { + background: linear-gradient(135deg, #10243f 0%, #174ea6 55%, #4f46e5 100%); + border-color: rgba(96, 165, 250, 0.5); + color: #dbeafe; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.06); +} +.btn-toolbar.btn-run.mode-gemini:hover, +.btn-toolbar.btn-run-gear.mode-gemini:hover { + background: linear-gradient(135deg, #17345f 0%, #2563eb 55%, #6366f1 100%); + box-shadow: 0 0 12px rgba(96, 165, 250, 0.35), 0 2px 8px rgba(99, 102, 241, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.08); + border-color: rgba(147, 197, 253, 0.6); + color: #eff6ff; +} + /* Dropdown menu */ .run-mode-menu { display: none; @@ -2963,6 +2994,7 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { .run-mode-dot.claude { background: #3b82f6; } .run-mode-dot.opencode { background: #10b981; } .run-mode-dot.codex { background: #a855f7; } +.run-mode-dot.gemini { background: #8ab4f8; } .run-mode-sep { height: 1px; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index c3ec4e33..94727fd9 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -286,12 +286,14 @@ export function registerSessionRoutes( // // For keys the caller is actively setting, strip any stale disk entry a prior // Codeman version may have written. Scope limited to: - // - Claude mode (OpenCode doesn't read .claude/settings.local.json) + // - Claude mode (OpenCode/Codex/Gemini don't read .claude/settings.local.json) // - workingDir inside CASES_DIR (Codeman's managed territory — we never mutate // .claude/settings.local.json in arbitrary user repos that POST /api/sessions // can target, because those may have hand-authored values). const canStripDisk = body.mode !== 'opencode' && + body.mode !== 'codex' && + body.mode !== 'gemini' && body.envOverrides && Object.keys(body.envOverrides).length > 0 && workingDir.startsWith(CASES_DIR + '/'); @@ -348,6 +350,17 @@ export function registerSessionRoutes( } } + // Check Gemini availability if requested + if (body.mode === 'gemini') { + const { isGeminiAvailable } = await import('../../utils/gemini-cli-resolver.js'); + if (!isGeminiAvailable()) { + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + 'Gemini CLI not found. Install with: npm install -g @google/gemini-cli' + ); + } + } + // Pre-validate resumeSessionId: check that the conversation file actually exists // in Claude's projects directory. If not, skip resume to avoid confusing // "No conversation found" errors from Claude CLI. @@ -386,9 +399,11 @@ export function registerSessionRoutes( ? body.openCodeConfig?.model : mode === 'codex' ? body.codexConfig?.model - : mode !== 'shell' - ? modelConfig?.defaultModel || undefined - : undefined; + : mode === 'gemini' + ? body.geminiConfig?.model + : mode !== 'shell' + ? modelConfig?.defaultModel || undefined + : undefined; const claudeModeConfig = await ctx.getClaudeModeConfig(); const session = new Session({ workingDir, @@ -402,6 +417,7 @@ export function registerSessionRoutes( allowedTools: claudeModeConfig.allowedTools, openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined, codexConfig: mode === 'codex' ? body.codexConfig : undefined, + geminiConfig: mode === 'gemini' ? body.geminiConfig : undefined, resumeSessionId: validatedResumeId, envOverrides: body.envOverrides, effort: body.effort, @@ -600,9 +616,11 @@ export function registerSessionRoutes( try { // Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled and not explicitly disabled by user) - // Ralph tracker is not supported for opencode sessions + // Ralph tracker is not supported for opencode / codex / gemini sessions if ( session.mode !== 'opencode' && + session.mode !== 'codex' && + session.mode !== 'gemini' && ctx.store.getConfig().ralphEnabled && !session.ralphTracker.autoEnableDisabled ) { @@ -1234,6 +1252,7 @@ export function registerSessionRoutes( mode = 'claude', openCodeConfig, codexConfig, + geminiConfig, envOverrides, effort, } = parseBody(QuickStartSchema, req.body); @@ -1260,6 +1279,17 @@ export function registerSessionRoutes( } } + // Check Gemini availability if requested + if (mode === 'gemini') { + const { isGeminiAvailable } = await import('../../utils/gemini-cli-resolver.js'); + if (!isGeminiAvailable()) { + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + 'Gemini CLI not found. Install with: npm install -g @google/gemini-cli' + ); + } + } + // Resolve case path: check linked-cases registry first, then fall back to CASES_DIR. // This mirrors the behaviour of resolveCasePath() in case-routes so that linked // external project directories are honoured by quick-start just like regular case routes. @@ -1288,8 +1318,8 @@ export function registerSessionRoutes( writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd); // Write .claude/settings.local.json with hooks for desktop notifications - // (Claude-specific — OpenCode uses its own plugin system) - if (mode !== 'opencode') { + // (Claude-specific — OpenCode, Codex, and Gemini use their own systems) + if (mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini') { await writeHooksConfig(casePath); } @@ -1306,7 +1336,13 @@ export function registerSessionRoutes( // Strip stale disk entries for keys this request is actively setting (Claude only — // see POST /api/sessions for full rationale). - if (mode !== 'opencode' && envOverrides && Object.keys(envOverrides).length > 0) { + if ( + mode !== 'opencode' && + mode !== 'codex' && + mode !== 'gemini' && + envOverrides && + Object.keys(envOverrides).length > 0 + ) { await stripCaseEnvKeys(casePath, Object.keys(envOverrides)); } @@ -1319,9 +1355,11 @@ export function registerSessionRoutes( ? openCodeConfig?.model : mode === 'codex' ? codexConfig?.model - : mode !== 'shell' - ? qsModelConfig?.defaultModel || undefined - : undefined; + : mode === 'gemini' + ? geminiConfig?.model + : mode !== 'shell' + ? qsModelConfig?.defaultModel || undefined + : undefined; const qsClaudeModeConfig = await ctx.getClaudeModeConfig(); const session = new Session({ workingDir: casePath, @@ -1334,6 +1372,7 @@ export function registerSessionRoutes( allowedTools: qsClaudeModeConfig.allowedTools, openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined, codexConfig: mode === 'codex' ? codexConfig : undefined, + geminiConfig: mode === 'gemini' ? geminiConfig : undefined, envOverrides, effort, }); @@ -1372,7 +1411,7 @@ export function registerSessionRoutes( }); ctx.broadcast(SseEvent.SessionInteractive, { id: session.id, mode: 'shell' }); } else { - // Both 'claude' and 'opencode' modes use startInteractive() + // 'claude', 'opencode', 'codex', and 'gemini' modes use startInteractive() await session.startInteractive(); getLifecycleLog().log({ event: 'started', diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index aaa69d21..e09f94fd 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -331,7 +331,7 @@ export function registerSystemRoutes( }); // ═══════════════════════════════════════════════════════════════ - // CLI Integrations (OpenCode) + // CLI Integrations (OpenCode, Codex, Gemini) // ═══════════════════════════════════════════════════════════════ // ========== OpenCode ========== @@ -352,6 +352,16 @@ export function registerSystemRoutes( }; }); + // ========== Gemini ========== + + app.get('/api/gemini/status', async () => { + const { isGeminiAvailable, resolveGeminiDir } = await import('../../utils/gemini-cli-resolver.js'); + return { + available: isGeminiAvailable(), + path: resolveGeminiDir(), + }; + }); + // ═══════════════════════════════════════════════════════════════ // State & Lifecycle (cleanup, lifecycle log, stats) // ═══════════════════════════════════════════════════════════════ diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 06457799..44f923b7 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -46,7 +46,7 @@ const safePathSchema = z.string().max(1000).refine(isValidWorkingDir, { // ========== Env Var Allowlist ========== /** Allowlisted env var key prefixes */ -const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_', 'CODEX_']; +const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_', 'CODEX_', 'GEMINI_', 'GOOGLE_']; /** Env var keys that are always blocked (security-sensitive) */ const BLOCKED_ENV_KEYS = new Set([ @@ -76,7 +76,7 @@ const safeEnvOverridesSchema = z }, { message: - 'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, and CODEX_* keys are allowed.', + 'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, and GOOGLE_* keys are allowed.', } ); @@ -149,9 +149,26 @@ const CodexConfigSchema = z }) .optional(); +/** Schema for Gemini CLI-specific configuration */ +const GeminiConfigSchema = z + .object({ + model: z + .string() + .max(100) + .regex(/^[a-zA-Z0-9._\-/]+$/) + .optional(), + approvalMode: z.enum(['default', 'auto_edit', 'yolo', 'plan']).optional(), + resumeSession: z + .string() + .max(100) + .regex(/^[a-zA-Z0-9._-]+$/) + .optional(), + }) + .optional(); + export const CreateSessionSchema = z.object({ workingDir: safePathSchema.optional(), - mode: z.enum(['claude', 'shell', 'opencode', 'codex']).optional(), + mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']).optional(), name: z.string().max(100).optional(), envOverrides: safeEnvOverridesSchema, /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ @@ -162,6 +179,7 @@ export const CreateSessionSchema = z.object({ statusLineTelemetry: z.boolean().optional(), openCodeConfig: OpenCodeConfigSchema, codexConfig: CodexConfigSchema, + geminiConfig: GeminiConfigSchema, /** Resume a previous Claude conversation by its session ID (used for reboot recovery) */ resumeSessionId: z .string() @@ -258,9 +276,10 @@ export const QuickStartSchema = z.object({ .string() .regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.') .optional(), - mode: z.enum(['claude', 'shell', 'opencode', 'codex']).optional(), + mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']).optional(), openCodeConfig: OpenCodeConfigSchema, codexConfig: CodexConfigSchema, + geminiConfig: GeminiConfigSchema, envOverrides: safeEnvOverridesSchema, /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ effort: effortLevelSchema, diff --git a/src/web/server.ts b/src/web/server.ts index a156ec11..084376a7 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -2123,6 +2123,9 @@ export class WebServer extends EventEmitter { muxSession: muxSession, // Pass the existing session so startInteractive() can attach to it claudeMode: recoveryClaudeMode.claudeMode, allowedTools: recoveryClaudeMode.allowedTools, + openCodeConfig: muxSession.mode === 'opencode' ? savedState?.openCodeConfig : undefined, + codexConfig: muxSession.mode === 'codex' ? savedState?.codexConfig : undefined, + geminiConfig: muxSession.mode === 'gemini' ? savedState?.geminiConfig : undefined, envOverrides: savedEnvOverrides, effort: savedState?.effort, attachmentHistory: savedAttachmentHistory, diff --git a/test/gemini-mode.test.ts b/test/gemini-mode.test.ts new file mode 100644 index 00000000..1ca9eaf7 --- /dev/null +++ b/test/gemini-mode.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { CreateSessionSchema, QuickStartSchema } from '../src/web/schemas.js'; + +describe('Gemini mode schemas', () => { + it('accepts Gemini session creation config', () => { + const parsed = CreateSessionSchema.parse({ + workingDir: '/tmp', + mode: 'gemini', + geminiConfig: { + model: 'gemini-2.5-pro', + approvalMode: 'yolo', + }, + }); + + expect(parsed.mode).toBe('gemini'); + expect(parsed.geminiConfig).toEqual({ + model: 'gemini-2.5-pro', + approvalMode: 'yolo', + }); + }); + + it('accepts Gemini quick-start config', () => { + const parsed = QuickStartSchema.parse({ + caseName: 'gemini-case', + mode: 'gemini', + geminiConfig: { + model: 'gemini-2.5-flash', + approvalMode: 'auto_edit', + }, + }); + + expect(parsed.mode).toBe('gemini'); + expect(parsed.geminiConfig?.model).toBe('gemini-2.5-flash'); + }); + + it('rejects unsafe Gemini model strings', () => { + expect(() => + CreateSessionSchema.parse({ + workingDir: '/tmp', + mode: 'gemini', + geminiConfig: { model: 'gemini; rm -rf /' }, + }) + ).toThrow(); + }); +}); diff --git a/test/routes/system-routes.test.ts b/test/routes/system-routes.test.ts index e5d4f87d..7714d5a2 100644 --- a/test/routes/system-routes.test.ts +++ b/test/routes/system-routes.test.ts @@ -76,11 +76,17 @@ vi.mock('../../src/utils/opencode-cli-resolver.js', () => ({ resolveOpenCodeDir: vi.fn(() => null), })); +vi.mock('../../src/utils/gemini-cli-resolver.js', () => ({ + isGeminiAvailable: vi.fn(() => false), + resolveGeminiDir: vi.fn(() => null), +})); + import fs from 'node:fs/promises'; import { existsSync, readdirSync } from 'node:fs'; import { subagentWatcher } from '../../src/subagent-watcher.js'; import { getLifecycleLog } from '../../src/session-lifecycle-log.js'; import { isOpenCodeAvailable, resolveOpenCodeDir } from '../../src/utils/opencode-cli-resolver.js'; +import { isGeminiAvailable, resolveGeminiDir } from '../../src/utils/gemini-cli-resolver.js'; const mockedReadFile = vi.mocked(fs.readFile); const mockedWriteFile = vi.mocked(fs.writeFile); @@ -90,6 +96,8 @@ const mockedSubagentWatcher = vi.mocked(subagentWatcher); const mockedGetLifecycleLog = vi.mocked(getLifecycleLog); const mockedIsOpenCodeAvailable = vi.mocked(isOpenCodeAvailable); const mockedResolveOpenCodeDir = vi.mocked(resolveOpenCodeDir); +const mockedIsGeminiAvailable = vi.mocked(isGeminiAvailable); +const mockedResolveGeminiDir = vi.mocked(resolveGeminiDir); describe('system-routes', () => { let harness: RouteTestHarness; @@ -117,6 +125,8 @@ describe('system-routes', () => { } as never); mockedIsOpenCodeAvailable.mockReturnValue(false); mockedResolveOpenCodeDir.mockReturnValue(null); + mockedIsGeminiAvailable.mockReturnValue(false); + mockedResolveGeminiDir.mockReturnValue(null); }); afterEach(async () => { @@ -167,7 +177,7 @@ describe('system-routes', () => { }); expect(res.statusCode).toBe(400); const body = JSON.parse(res.body); - expect(body.success).toBe(false); + expect(body.message ?? body.error).toBeTruthy(); }); }); @@ -356,7 +366,7 @@ describe('system-routes', () => { }); expect(res.statusCode).toBe(400); const body = JSON.parse(res.body); - expect(body.success).toBe(false); + expect(body.message ?? body.error).toBeTruthy(); }); it('saves lastUsedCase as partial update without overwriting other settings', async () => { @@ -454,7 +464,7 @@ describe('system-routes', () => { }); expect(res.statusCode).toBe(400); const body = JSON.parse(res.body); - expect(body.success).toBe(false); + expect(body.message ?? body.error).toBeTruthy(); }); }); @@ -511,7 +521,7 @@ describe('system-routes', () => { }); expect(res.statusCode).toBe(400); const body = JSON.parse(res.body); - expect(body.success).toBe(false); + expect(body.message ?? body.error).toBeTruthy(); }); }); @@ -699,6 +709,32 @@ describe('system-routes', () => { }); }); + // ========== GET /api/gemini/status ========== + + describe('GET /api/gemini/status', () => { + it('returns unavailable when gemini is not installed', async () => { + mockedIsGeminiAvailable.mockReturnValue(false); + mockedResolveGeminiDir.mockReturnValue(null); + + const res = await harness.app.inject({ method: 'GET', url: '/api/gemini/status' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.available).toBe(false); + expect(body.path).toBeNull(); + }); + + it('returns available with path when gemini is installed', async () => { + mockedIsGeminiAvailable.mockReturnValue(true); + mockedResolveGeminiDir.mockReturnValue('/usr/local/bin'); + + const res = await harness.app.inject({ method: 'GET', url: '/api/gemini/status' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.available).toBe(true); + expect(body.path).toBe('/usr/local/bin'); + }); + }); + // ========== GET /api/execution/model-config ========== describe('GET /api/execution/model-config', () => { diff --git a/test/run-mode-ui.test.ts b/test/run-mode-ui.test.ts index 3624b4f2..07dc1053 100644 --- a/test/run-mode-ui.test.ts +++ b/test/run-mode-ui.test.ts @@ -61,6 +61,16 @@ describe('run mode UI', () => { expect(app.runMode).toBe('claude'); expect(runBtnLabel.textContent).toBe('Run'); }); + + it('accepts Gemini mode from server sync and updates the run button label', async () => { + const { app, storage, runBtnLabel } = loadRunModeHarness(); + + storage.set('codeman_runMode', 'claude'); + await app.loadAppSettingsFromServer(Promise.resolve({ runMode: 'gemini' })); + + expect(app.runMode).toBe('gemini'); + expect(runBtnLabel.textContent).toBe('Run GM'); + }); }); describe('Codex quick start settings', () => {