mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 08:29:42 +02:00
Merge PR #134: Gemini run mode (third external-CLI mode alongside Codex/OpenCode) (COD-36)
This commit is contained in:
@@ -16,6 +16,7 @@ import type {
|
|||||||
OpenCodeConfig,
|
OpenCodeConfig,
|
||||||
CodexConfig,
|
CodexConfig,
|
||||||
EffortLevel,
|
EffortLevel,
|
||||||
|
GeminiConfig,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,6 +65,7 @@ export interface CreateSessionOptions {
|
|||||||
allowedTools?: string;
|
allowedTools?: string;
|
||||||
openCodeConfig?: OpenCodeConfig;
|
openCodeConfig?: OpenCodeConfig;
|
||||||
codexConfig?: CodexConfig;
|
codexConfig?: CodexConfig;
|
||||||
|
geminiConfig?: GeminiConfig;
|
||||||
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
|
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */
|
/** 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;
|
allowedTools?: string;
|
||||||
openCodeConfig?: OpenCodeConfig;
|
openCodeConfig?: OpenCodeConfig;
|
||||||
codexConfig?: CodexConfig;
|
codexConfig?: CodexConfig;
|
||||||
|
geminiConfig?: GeminiConfig;
|
||||||
/** Resume a previous Claude conversation when respawning */
|
/** Resume a previous Claude conversation when respawning */
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
/** Extra env vars exported before launching the CLI (preserved across respawns). */
|
/** Extra env vars exported before launching the CLI (preserved across respawns). */
|
||||||
|
|||||||
+34
-2
@@ -48,6 +48,7 @@ import {
|
|||||||
type OpenCodeConfig,
|
type OpenCodeConfig,
|
||||||
type CodexConfig,
|
type CodexConfig,
|
||||||
type EffortLevel,
|
type EffortLevel,
|
||||||
|
type GeminiConfig,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
|
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
|
||||||
import { TaskTracker, type BackgroundTask } from './task-tracker.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. */
|
/** True for external-CLI run modes (non-Claude) that use their own TUI and output format. */
|
||||||
export function isExternalCliMode(mode: SessionMode): boolean {
|
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;
|
private _openCodeConfig: OpenCodeConfig | undefined;
|
||||||
// Codex configuration (only for mode === 'codex')
|
// Codex configuration (only for mode === 'codex')
|
||||||
private _codexConfig: CodexConfig | undefined;
|
private _codexConfig: CodexConfig | undefined;
|
||||||
|
// Gemini configuration (only for mode === 'gemini')
|
||||||
|
private _geminiConfig: GeminiConfig | undefined;
|
||||||
private _resumeSessionId: string | undefined;
|
private _resumeSessionId: string | undefined;
|
||||||
|
|
||||||
// Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux
|
// Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux
|
||||||
@@ -421,6 +439,8 @@ export class Session extends EventEmitter {
|
|||||||
openCodeConfig?: OpenCodeConfig;
|
openCodeConfig?: OpenCodeConfig;
|
||||||
/** Codex configuration (only for mode === 'codex') */
|
/** Codex configuration (only for mode === 'codex') */
|
||||||
codexConfig?: CodexConfig;
|
codexConfig?: CodexConfig;
|
||||||
|
/** Gemini configuration (only for mode === 'gemini') */
|
||||||
|
geminiConfig?: GeminiConfig;
|
||||||
/** Resume a previous Claude conversation (used after server reboot) */
|
/** Resume a previous Claude conversation (used after server reboot) */
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */
|
/** 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;
|
this._codexConfig = config.codexConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply Gemini configuration
|
||||||
|
if (config.geminiConfig) {
|
||||||
|
this._geminiConfig = config.geminiConfig;
|
||||||
|
}
|
||||||
|
|
||||||
// Apply env overrides (exported at spawn, not persisted to disk).
|
// 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,
|
// 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)
|
// 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,
|
cliLatestVersion: this._cliLatestVersion || undefined,
|
||||||
openCodeConfig: this._openCodeConfig,
|
openCodeConfig: this._openCodeConfig,
|
||||||
codexConfig: this._codexConfig,
|
codexConfig: this._codexConfig,
|
||||||
|
geminiConfig: this._geminiConfig,
|
||||||
resumeSessionId: this._resumeSessionId,
|
resumeSessionId: this._resumeSessionId,
|
||||||
effort: this._effort,
|
effort: this._effort,
|
||||||
attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined,
|
attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined,
|
||||||
@@ -1224,7 +1250,7 @@ export class Session extends EventEmitter {
|
|||||||
|
|
||||||
this._resetBuffers();
|
this._resetBuffers();
|
||||||
|
|
||||||
const modeLabel = this.mode === 'opencode' ? 'OpenCode' : this.mode === 'codex' ? 'Codex' : 'Claude';
|
const modeLabel = getModeLabel(this.mode);
|
||||||
console.log(
|
console.log(
|
||||||
`[Session] Starting interactive ${modeLabel} session` + (this._useMux ? ` (with ${this._mux!.backend})` : '')
|
`[Session] Starting interactive ${modeLabel} session` + (this._useMux ? ` (with ${this._mux!.backend})` : '')
|
||||||
);
|
);
|
||||||
@@ -1243,6 +1269,7 @@ export class Session extends EventEmitter {
|
|||||||
allowedTools: this._allowedTools,
|
allowedTools: this._allowedTools,
|
||||||
openCodeConfig: this._openCodeConfig,
|
openCodeConfig: this._openCodeConfig,
|
||||||
codexConfig: this._codexConfig,
|
codexConfig: this._codexConfig,
|
||||||
|
geminiConfig: this._geminiConfig,
|
||||||
resumeSessionId: this._resumeSessionId,
|
resumeSessionId: this._resumeSessionId,
|
||||||
envOverrides: this._envOverrides,
|
envOverrides: this._envOverrides,
|
||||||
effort: this._effort,
|
effort: this._effort,
|
||||||
@@ -1258,6 +1285,7 @@ export class Session extends EventEmitter {
|
|||||||
allowedTools: this._allowedTools,
|
allowedTools: this._allowedTools,
|
||||||
openCodeConfig: this._openCodeConfig,
|
openCodeConfig: this._openCodeConfig,
|
||||||
codexConfig: this._codexConfig,
|
codexConfig: this._codexConfig,
|
||||||
|
geminiConfig: this._geminiConfig,
|
||||||
resumeSessionId: this._resumeSessionId,
|
resumeSessionId: this._resumeSessionId,
|
||||||
envOverrides: this._envOverrides,
|
envOverrides: this._envOverrides,
|
||||||
effort: this._effort,
|
effort: this._effort,
|
||||||
@@ -1331,6 +1359,10 @@ export class Session extends EventEmitter {
|
|||||||
if (this.mode === 'codex') {
|
if (this.mode === 'codex') {
|
||||||
throw new Error('Codex sessions require tmux. Direct PTY fallback is not supported.');
|
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 {
|
try {
|
||||||
// Pass --session-id to use the SAME ID as the Codeman session
|
// Pass --session-id to use the SAME ID as the Codeman session
|
||||||
// This ensures subagents can be directly matched to the correct tab
|
// This ensures subagents can be directly matched to the correct tab
|
||||||
|
|||||||
+103
-1
@@ -41,9 +41,17 @@ import {
|
|||||||
type OpenCodeConfig,
|
type OpenCodeConfig,
|
||||||
type CodexConfig,
|
type CodexConfig,
|
||||||
type EffortLevel,
|
type EffortLevel,
|
||||||
|
type GeminiConfig,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { buildEffortCliArgs } from './session-cli-builder.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 {
|
import type {
|
||||||
TerminalMultiplexer,
|
TerminalMultiplexer,
|
||||||
MuxSession,
|
MuxSession,
|
||||||
@@ -571,6 +579,35 @@ export function buildCodexCommand(config?: CodexConfig): string {
|
|||||||
return parts.join(' ');
|
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.
|
* Build the spawn command for any session mode.
|
||||||
* Shared by createSession() and respawnPane() to avoid duplication.
|
* Shared by createSession() and respawnPane() to avoid duplication.
|
||||||
@@ -597,6 +634,7 @@ function buildSpawnCommand(options: {
|
|||||||
allowedTools?: string;
|
allowedTools?: string;
|
||||||
openCodeConfig?: OpenCodeConfig;
|
openCodeConfig?: OpenCodeConfig;
|
||||||
codexConfig?: CodexConfig;
|
codexConfig?: CodexConfig;
|
||||||
|
geminiConfig?: GeminiConfig;
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
}): string {
|
}): string {
|
||||||
@@ -624,6 +662,9 @@ function buildSpawnCommand(options: {
|
|||||||
if (options.mode === 'codex') {
|
if (options.mode === 'codex') {
|
||||||
return buildCodexCommand(options.codexConfig);
|
return buildCodexCommand(options.codexConfig);
|
||||||
}
|
}
|
||||||
|
if (options.mode === 'gemini') {
|
||||||
|
return buildGeminiCommand(options.geminiConfig);
|
||||||
|
}
|
||||||
return '$SHELL';
|
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.
|
* Set OPENCODE_CONFIG_CONTENT on a tmux session via setenv.
|
||||||
* Uses tmux setenv to avoid shell metacharacter injection from user-supplied JSON.
|
* 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();
|
const dir = resolveCodexDir();
|
||||||
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
|
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 };
|
return { pathExport: '', dir: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -953,6 +1030,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
setCodexEnvVars(this.tmux(), muxName);
|
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.
|
* Creates a new tmux session wrapping Claude CLI or a shell.
|
||||||
* In test mode: creates an in-memory session only (no real tmux session).
|
* 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,
|
allowedTools,
|
||||||
openCodeConfig,
|
openCodeConfig,
|
||||||
codexConfig,
|
codexConfig,
|
||||||
|
geminiConfig,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
envOverrides,
|
envOverrides,
|
||||||
effort,
|
effort,
|
||||||
@@ -1007,6 +1092,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
if (mode === 'opencode' && !cliDir) {
|
if (mode === 'opencode' && !cliDir) {
|
||||||
throw new Error('OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash');
|
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(' && ');
|
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
|
||||||
|
|
||||||
@@ -1018,6 +1109,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
allowedTools,
|
allowedTools,
|
||||||
openCodeConfig,
|
openCodeConfig,
|
||||||
codexConfig,
|
codexConfig,
|
||||||
|
geminiConfig,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
effort,
|
effort,
|
||||||
});
|
});
|
||||||
@@ -1067,6 +1159,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
} else if (mode === 'codex') {
|
} else if (mode === 'codex') {
|
||||||
this._configureCodex(muxName);
|
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
|
// 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.
|
// 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,
|
allowedTools,
|
||||||
openCodeConfig,
|
openCodeConfig,
|
||||||
codexConfig,
|
codexConfig,
|
||||||
|
geminiConfig,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
envOverrides,
|
envOverrides,
|
||||||
effort,
|
effort,
|
||||||
@@ -1247,6 +1344,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
allowedTools,
|
allowedTools,
|
||||||
openCodeConfig,
|
openCodeConfig,
|
||||||
codexConfig,
|
codexConfig,
|
||||||
|
geminiConfig,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
effort,
|
effort,
|
||||||
});
|
});
|
||||||
@@ -1261,6 +1359,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
} else if (mode === 'codex') {
|
} else if (mode === 'codex') {
|
||||||
this._configureCodex(muxName);
|
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.
|
// Re-apply user env overrides before respawn so the new shell inherits them.
|
||||||
this.applyEnvOverrides(muxName, envOverrides);
|
this.applyEnvOverrides(muxName, envOverrides);
|
||||||
|
|||||||
+16
-2
@@ -8,10 +8,12 @@
|
|||||||
* - SessionConfig — creation-time config (id, workingDir, createdAt)
|
* - SessionConfig — creation-time config (id, workingDir, createdAt)
|
||||||
* - SessionOutput — captured stdout/stderr/exitCode
|
* - SessionOutput — captured stdout/stderr/exitCode
|
||||||
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
|
* - 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')
|
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'normal' | 'allowedTools')
|
||||||
* - SessionColor — visual differentiation color
|
* - SessionColor — visual differentiation color
|
||||||
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
|
* - 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:
|
* Cross-domain relationships:
|
||||||
* - SessionState.respawnConfig embeds RespawnConfig (respawn domain)
|
* - 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';
|
export type ClaudeMode = 'dangerously-skip-permissions' | 'normal' | 'allowedTools';
|
||||||
|
|
||||||
/** Session mode: which CLI backend a session runs */
|
/** 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).
|
* Valid Claude CLI effort levels (claude >= 2.1.154).
|
||||||
@@ -85,6 +87,16 @@ export interface CodexConfig {
|
|||||||
renderMode?: CodexRenderMode;
|
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
|
* Configuration for creating a new session
|
||||||
*/
|
*/
|
||||||
@@ -214,6 +226,8 @@ export interface SessionState {
|
|||||||
openCodeConfig?: OpenCodeConfig;
|
openCodeConfig?: OpenCodeConfig;
|
||||||
/** Codex-specific configuration (only for mode === 'codex') */
|
/** Codex-specific configuration (only for mode === 'codex') */
|
||||||
codexConfig?: CodexConfig;
|
codexConfig?: CodexConfig;
|
||||||
|
/** Gemini-specific configuration (only for mode === 'gemini') */
|
||||||
|
geminiConfig?: GeminiConfig;
|
||||||
/** Claude conversation session ID to resume after reboot (set by restore script) */
|
/** Claude conversation session ID to resume after reboot (set by restore script) */
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -29,3 +29,4 @@ export { wrapWithNice } from './nice-wrapper.js';
|
|||||||
export { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.js';
|
export { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.js';
|
||||||
export { resolveOpenCodeDir } from './opencode-cli-resolver.js';
|
export { resolveOpenCodeDir } from './opencode-cli-resolver.js';
|
||||||
export { resolveCodexDir, isCodexAvailable } from './codex-cli-resolver.js';
|
export { resolveCodexDir, isCodexAvailable } from './codex-cli-resolver.js';
|
||||||
|
export { resolveGeminiDir } from './gemini-cli-resolver.js';
|
||||||
|
|||||||
@@ -306,6 +306,10 @@
|
|||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||||
Run OpenCode
|
Run OpenCode
|
||||||
</button>
|
</button>
|
||||||
|
<button class="welcome-btn welcome-btn-gemini" onclick="app.setRunMode('gemini'); app.runGemini()">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||||
|
Run Gemini
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="welcome-qr" id="welcomeQr" onclick="app.toggleWelcomeQrSize()">
|
<div class="welcome-qr" id="welcomeQr" onclick="app.toggleWelcomeQrSize()">
|
||||||
<div class="welcome-qr-inner" id="welcomeQrInner"></div>
|
<div class="welcome-qr-inner" id="welcomeQrInner"></div>
|
||||||
@@ -440,6 +444,9 @@
|
|||||||
<button class="run-mode-option" data-mode="codex" onclick="app.setRunMode('codex')">
|
<button class="run-mode-option" data-mode="codex" onclick="app.setRunMode('codex')">
|
||||||
<span class="run-mode-dot codex"></span>Codex
|
<span class="run-mode-dot codex"></span>Codex
|
||||||
</button>
|
</button>
|
||||||
|
<button class="run-mode-option" data-mode="gemini" onclick="app.setRunMode('gemini')">
|
||||||
|
<span class="run-mode-dot gemini"></span>Gemini
|
||||||
|
</button>
|
||||||
<div class="run-mode-sep"></div>
|
<div class="run-mode-sep"></div>
|
||||||
<div class="run-mode-header">Recent Sessions</div>
|
<div class="run-mode-header">Recent Sessions</div>
|
||||||
<div class="run-mode-history" id="runModeHistory"></div>
|
<div class="run-mode-history" id="runModeHistory"></div>
|
||||||
|
|||||||
@@ -780,6 +780,20 @@ html.mobile-init .file-browser-panel {
|
|||||||
border-color: rgba(16, 185, 129, 0.5);
|
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 dropdown menu — positioned above toolbar on mobile */
|
||||||
.run-mode-menu {
|
.run-mode-menu {
|
||||||
bottom: 100%;
|
bottom: 100%;
|
||||||
|
|||||||
@@ -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 modal (per-session settings, color picker, rename),
|
||||||
* session options tabs (Ralph config tab), case settings (CRUD, links),
|
* session options tabs (Ralph config tab), case settings (CRUD, links),
|
||||||
* create case modal, and mobile case picker.
|
* create case modal, and mobile case picker.
|
||||||
@@ -151,7 +151,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return this.run();
|
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() {
|
async run() {
|
||||||
const mode = this._runMode || 'claude';
|
const mode = this._runMode || 'claude';
|
||||||
if (mode === 'opencode') {
|
if (mode === 'opencode') {
|
||||||
@@ -160,6 +160,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (mode === 'codex') {
|
if (mode === 'codex') {
|
||||||
return this.runCodex();
|
return this.runCodex();
|
||||||
}
|
}
|
||||||
|
if (mode === 'gemini') {
|
||||||
|
return this.runGemini();
|
||||||
|
}
|
||||||
return this.runClaude();
|
return this.runClaude();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -257,7 +260,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
|
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
|
||||||
}
|
}
|
||||||
if (label) {
|
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
|
// Session Options Modal
|
||||||
@@ -651,8 +695,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
this.editingSessionId = sessionId;
|
this.editingSessionId = sessionId;
|
||||||
|
|
||||||
// Reset to an appropriate tab — Summary for OpenCode (Respawn/Ralph are Claude-only)
|
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
|
||||||
this.switchOptionsTab(session.mode === 'opencode' || session.mode === 'codex' ? 'summary' : 'respawn');
|
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini';
|
||||||
|
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
|
||||||
|
|
||||||
// Update respawn status display and buttons
|
// Update respawn status display and buttons
|
||||||
const respawnStatus = document.getElementById('sessionRespawnStatus');
|
const respawnStatus = document.getElementById('sessionRespawnStatus');
|
||||||
@@ -680,10 +725,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
respawnSection.style.display = 'none';
|
respawnSection.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide Claude-specific options for OpenCode sessions
|
// Hide Claude-specific options for external CLI sessions
|
||||||
const isOpenCode = session.mode === 'opencode' || session.mode === 'codex';
|
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini';
|
||||||
const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
|
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)
|
// Reset duration presets to default (unlimited)
|
||||||
this.selectDurationPreset('');
|
this.selectDurationPreset('');
|
||||||
@@ -731,21 +776,21 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
document.getElementById('respawnPresetSelect').value = '';
|
document.getElementById('respawnPresetSelect').value = '';
|
||||||
document.getElementById('presetDescriptionHint').textContent = '';
|
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 ralphTabBtn = document.querySelector('#sessionOptionsModal .modal-tab-btn[data-tab="ralph"]');
|
||||||
const respawnTabBtn = document.querySelector('#sessionOptionsModal .modal-tab-btn[data-tab="respawn"]');
|
const respawnTabBtn = document.querySelector('#sessionOptionsModal .modal-tab-btn[data-tab="respawn"]');
|
||||||
if (isOpenCode) {
|
if (isExternalCli) {
|
||||||
if (ralphTabBtn) ralphTabBtn.style.display = 'none';
|
if (ralphTabBtn) ralphTabBtn.style.display = 'none';
|
||||||
if (respawnTabBtn) respawnTabBtn.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');
|
this.switchOptionsTab('context');
|
||||||
} else {
|
} else {
|
||||||
if (ralphTabBtn) ralphTabBtn.style.display = '';
|
if (ralphTabBtn) ralphTabBtn.style.display = '';
|
||||||
if (respawnTabBtn) respawnTabBtn.style.display = '';
|
if (respawnTabBtn) respawnTabBtn.style.display = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Populate Ralph Wiggum form with current session values (skip for opencode)
|
// Populate Ralph Wiggum form with current session values (skip for external CLI sessions)
|
||||||
if (!isOpenCode) {
|
if (!isExternalCli) {
|
||||||
const ralphState = this.ralphStates.get(sessionId);
|
const ralphState = this.ralphStates.get(sessionId);
|
||||||
this.populateRalphForm({
|
this.populateRalphForm({
|
||||||
enabled: ralphState?.loop?.enabled ?? session.ralphLoop?.enabled ?? false,
|
enabled: ralphState?.loop?.enabled ?? session.ralphLoop?.enabled ?? false,
|
||||||
@@ -1579,6 +1624,7 @@ Object.defineProperty(CodemanApp.prototype, 'runMode', {
|
|||||||
return this._runMode || 'claude';
|
return this._runMode || 'claude';
|
||||||
},
|
},
|
||||||
set(mode) {
|
set(mode) {
|
||||||
this._runMode = mode === 'opencode' || mode === 'codex' || mode === 'claude' ? mode : 'claude';
|
this._runMode =
|
||||||
|
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'claude' ? mode : 'claude';
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2362,6 +2362,21 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
|||||||
transform: translateY(-1px);
|
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 {
|
.welcome-btn-tunnel {
|
||||||
background: linear-gradient(135deg, #221538 0%, #3b1a7a 50%, #6d28d9 100%);
|
background: linear-gradient(135deg, #221538 0%, #3b1a7a 50%, #6d28d9 100%);
|
||||||
border-color: rgba(124, 58, 237, 0.4);
|
border-color: rgba(124, 58, 237, 0.4);
|
||||||
@@ -3168,6 +3183,22 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
|||||||
color: #e9d5ff;
|
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 */
|
/* Dropdown menu */
|
||||||
.run-mode-menu {
|
.run-mode-menu {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -3221,6 +3252,7 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
|||||||
.run-mode-dot.claude { background: #3b82f6; }
|
.run-mode-dot.claude { background: #3b82f6; }
|
||||||
.run-mode-dot.opencode { background: #10b981; }
|
.run-mode-dot.opencode { background: #10b981; }
|
||||||
.run-mode-dot.codex { background: #a855f7; }
|
.run-mode-dot.codex { background: #a855f7; }
|
||||||
|
.run-mode-dot.gemini { background: #8ab4f8; }
|
||||||
|
|
||||||
.run-mode-sep {
|
.run-mode-sep {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
|
|||||||
@@ -287,12 +287,14 @@ export function registerSessionRoutes(
|
|||||||
//
|
//
|
||||||
// For keys the caller is actively setting, strip any stale disk entry a prior
|
// For keys the caller is actively setting, strip any stale disk entry a prior
|
||||||
// Codeman version may have written. Scope limited to:
|
// 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
|
// - workingDir inside CASES_DIR (Codeman's managed territory — we never mutate
|
||||||
// .claude/settings.local.json in arbitrary user repos that POST /api/sessions
|
// .claude/settings.local.json in arbitrary user repos that POST /api/sessions
|
||||||
// can target, because those may have hand-authored values).
|
// can target, because those may have hand-authored values).
|
||||||
const canStripDisk =
|
const canStripDisk =
|
||||||
body.mode !== 'opencode' &&
|
body.mode !== 'opencode' &&
|
||||||
|
body.mode !== 'codex' &&
|
||||||
|
body.mode !== 'gemini' &&
|
||||||
body.envOverrides &&
|
body.envOverrides &&
|
||||||
Object.keys(body.envOverrides).length > 0 &&
|
Object.keys(body.envOverrides).length > 0 &&
|
||||||
workingDir.startsWith(CASES_DIR + '/');
|
workingDir.startsWith(CASES_DIR + '/');
|
||||||
@@ -349,6 +351,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
|
// Pre-validate resumeSessionId: check that the conversation file actually exists
|
||||||
// in Claude's projects directory. If not, skip resume to avoid confusing
|
// in Claude's projects directory. If not, skip resume to avoid confusing
|
||||||
// "No conversation found" errors from Claude CLI.
|
// "No conversation found" errors from Claude CLI.
|
||||||
@@ -387,9 +400,11 @@ export function registerSessionRoutes(
|
|||||||
? body.openCodeConfig?.model
|
? body.openCodeConfig?.model
|
||||||
: mode === 'codex'
|
: mode === 'codex'
|
||||||
? body.codexConfig?.model
|
? body.codexConfig?.model
|
||||||
: mode !== 'shell'
|
: mode === 'gemini'
|
||||||
? modelConfig?.defaultModel || undefined
|
? body.geminiConfig?.model
|
||||||
: undefined;
|
: mode !== 'shell'
|
||||||
|
? modelConfig?.defaultModel || undefined
|
||||||
|
: undefined;
|
||||||
const claudeModeConfig = await ctx.getClaudeModeConfig();
|
const claudeModeConfig = await ctx.getClaudeModeConfig();
|
||||||
const session = new Session({
|
const session = new Session({
|
||||||
workingDir,
|
workingDir,
|
||||||
@@ -403,6 +418,7 @@ export function registerSessionRoutes(
|
|||||||
allowedTools: claudeModeConfig.allowedTools,
|
allowedTools: claudeModeConfig.allowedTools,
|
||||||
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
|
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
|
||||||
codexConfig: mode === 'codex' ? body.codexConfig : undefined,
|
codexConfig: mode === 'codex' ? body.codexConfig : undefined,
|
||||||
|
geminiConfig: mode === 'gemini' ? body.geminiConfig : undefined,
|
||||||
resumeSessionId: validatedResumeId,
|
resumeSessionId: validatedResumeId,
|
||||||
envOverrides: body.envOverrides,
|
envOverrides: body.envOverrides,
|
||||||
effort: body.effort,
|
effort: body.effort,
|
||||||
@@ -601,9 +617,11 @@ export function registerSessionRoutes(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled and not explicitly disabled by user)
|
// 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 (
|
if (
|
||||||
session.mode !== 'opencode' &&
|
session.mode !== 'opencode' &&
|
||||||
|
session.mode !== 'codex' &&
|
||||||
|
session.mode !== 'gemini' &&
|
||||||
ctx.store.getConfig().ralphEnabled &&
|
ctx.store.getConfig().ralphEnabled &&
|
||||||
!session.ralphTracker.autoEnableDisabled
|
!session.ralphTracker.autoEnableDisabled
|
||||||
) {
|
) {
|
||||||
@@ -1235,6 +1253,7 @@ export function registerSessionRoutes(
|
|||||||
mode = 'claude',
|
mode = 'claude',
|
||||||
openCodeConfig,
|
openCodeConfig,
|
||||||
codexConfig,
|
codexConfig,
|
||||||
|
geminiConfig,
|
||||||
envOverrides,
|
envOverrides,
|
||||||
effort,
|
effort,
|
||||||
} = parseBody(QuickStartSchema, req.body);
|
} = parseBody(QuickStartSchema, req.body);
|
||||||
@@ -1261,6 +1280,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.
|
// 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
|
// 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.
|
// external project directories are honoured by quick-start just like regular case routes.
|
||||||
@@ -1289,8 +1319,8 @@ export function registerSessionRoutes(
|
|||||||
writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
|
writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
|
||||||
|
|
||||||
// Write .claude/settings.local.json with hooks for desktop notifications
|
// Write .claude/settings.local.json with hooks for desktop notifications
|
||||||
// (Claude-specific — OpenCode uses its own plugin system)
|
// (Claude-specific — OpenCode, Codex, and Gemini use their own systems)
|
||||||
if (mode !== 'opencode') {
|
if (mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini') {
|
||||||
await writeHooksConfig(casePath);
|
await writeHooksConfig(casePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1307,7 +1337,13 @@ export function registerSessionRoutes(
|
|||||||
|
|
||||||
// Strip stale disk entries for keys this request is actively setting (Claude only —
|
// Strip stale disk entries for keys this request is actively setting (Claude only —
|
||||||
// see POST /api/sessions for full rationale).
|
// 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));
|
await stripCaseEnvKeys(casePath, Object.keys(envOverrides));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1320,9 +1356,11 @@ export function registerSessionRoutes(
|
|||||||
? openCodeConfig?.model
|
? openCodeConfig?.model
|
||||||
: mode === 'codex'
|
: mode === 'codex'
|
||||||
? codexConfig?.model
|
? codexConfig?.model
|
||||||
: mode !== 'shell'
|
: mode === 'gemini'
|
||||||
? qsModelConfig?.defaultModel || undefined
|
? geminiConfig?.model
|
||||||
: undefined;
|
: mode !== 'shell'
|
||||||
|
? qsModelConfig?.defaultModel || undefined
|
||||||
|
: undefined;
|
||||||
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
|
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||||||
const session = new Session({
|
const session = new Session({
|
||||||
workingDir: casePath,
|
workingDir: casePath,
|
||||||
@@ -1335,6 +1373,7 @@ export function registerSessionRoutes(
|
|||||||
allowedTools: qsClaudeModeConfig.allowedTools,
|
allowedTools: qsClaudeModeConfig.allowedTools,
|
||||||
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
|
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
|
||||||
codexConfig: mode === 'codex' ? codexConfig : undefined,
|
codexConfig: mode === 'codex' ? codexConfig : undefined,
|
||||||
|
geminiConfig: mode === 'gemini' ? geminiConfig : undefined,
|
||||||
envOverrides,
|
envOverrides,
|
||||||
effort,
|
effort,
|
||||||
});
|
});
|
||||||
@@ -1373,7 +1412,7 @@ export function registerSessionRoutes(
|
|||||||
});
|
});
|
||||||
ctx.broadcast(SseEvent.SessionInteractive, { id: session.id, mode: 'shell' });
|
ctx.broadcast(SseEvent.SessionInteractive, { id: session.id, mode: 'shell' });
|
||||||
} else {
|
} else {
|
||||||
// Both 'claude' and 'opencode' modes use startInteractive()
|
// 'claude', 'opencode', 'codex', and 'gemini' modes use startInteractive()
|
||||||
await session.startInteractive();
|
await session.startInteractive();
|
||||||
getLifecycleLog().log({
|
getLifecycleLog().log({
|
||||||
event: 'started',
|
event: 'started',
|
||||||
|
|||||||
@@ -343,7 +343,7 @@ export function registerSystemRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// CLI Integrations (OpenCode)
|
// CLI Integrations (OpenCode, Codex, Gemini)
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// ========== OpenCode ==========
|
// ========== OpenCode ==========
|
||||||
@@ -364,6 +364,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)
|
// State & Lifecycle (cleanup, lifecycle log, stats)
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|||||||
+23
-4
@@ -46,7 +46,7 @@ const safePathSchema = z.string().max(1000).refine(isValidWorkingDir, {
|
|||||||
// ========== Env Var Allowlist ==========
|
// ========== Env Var Allowlist ==========
|
||||||
|
|
||||||
/** Allowlisted env var key prefixes */
|
/** 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) */
|
/** Env var keys that are always blocked (security-sensitive) */
|
||||||
const BLOCKED_ENV_KEYS = new Set([
|
const BLOCKED_ENV_KEYS = new Set([
|
||||||
@@ -76,7 +76,7 @@ const safeEnvOverridesSchema = z
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
message:
|
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();
|
.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({
|
export const CreateSessionSchema = z.object({
|
||||||
workingDir: safePathSchema.optional(),
|
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(),
|
name: z.string().max(100).optional(),
|
||||||
envOverrides: safeEnvOverridesSchema,
|
envOverrides: safeEnvOverridesSchema,
|
||||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
/** 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(),
|
statusLineTelemetry: z.boolean().optional(),
|
||||||
openCodeConfig: OpenCodeConfigSchema,
|
openCodeConfig: OpenCodeConfigSchema,
|
||||||
codexConfig: CodexConfigSchema,
|
codexConfig: CodexConfigSchema,
|
||||||
|
geminiConfig: GeminiConfigSchema,
|
||||||
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
|
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
|
||||||
resumeSessionId: z
|
resumeSessionId: z
|
||||||
.string()
|
.string()
|
||||||
@@ -258,9 +276,10 @@ export const QuickStartSchema = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.')
|
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.')
|
||||||
.optional(),
|
.optional(),
|
||||||
mode: z.enum(['claude', 'shell', 'opencode', 'codex']).optional(),
|
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']).optional(),
|
||||||
openCodeConfig: OpenCodeConfigSchema,
|
openCodeConfig: OpenCodeConfigSchema,
|
||||||
codexConfig: CodexConfigSchema,
|
codexConfig: CodexConfigSchema,
|
||||||
|
geminiConfig: GeminiConfigSchema,
|
||||||
envOverrides: safeEnvOverridesSchema,
|
envOverrides: safeEnvOverridesSchema,
|
||||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||||
effort: effortLevelSchema,
|
effort: effortLevelSchema,
|
||||||
|
|||||||
@@ -2126,6 +2126,9 @@ export class WebServer extends EventEmitter {
|
|||||||
muxSession: muxSession, // Pass the existing session so startInteractive() can attach to it
|
muxSession: muxSession, // Pass the existing session so startInteractive() can attach to it
|
||||||
claudeMode: recoveryClaudeMode.claudeMode,
|
claudeMode: recoveryClaudeMode.claudeMode,
|
||||||
allowedTools: recoveryClaudeMode.allowedTools,
|
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,
|
envOverrides: savedEnvOverrides,
|
||||||
effort: savedState?.effort,
|
effort: savedState?.effort,
|
||||||
attachmentHistory: savedAttachmentHistory,
|
attachmentHistory: savedAttachmentHistory,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -76,11 +76,17 @@ vi.mock('../../src/utils/opencode-cli-resolver.js', () => ({
|
|||||||
resolveOpenCodeDir: vi.fn(() => null),
|
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 fs from 'node:fs/promises';
|
||||||
import { existsSync, readdirSync } from 'node:fs';
|
import { existsSync, readdirSync } from 'node:fs';
|
||||||
import { subagentWatcher } from '../../src/subagent-watcher.js';
|
import { subagentWatcher } from '../../src/subagent-watcher.js';
|
||||||
import { getLifecycleLog } from '../../src/session-lifecycle-log.js';
|
import { getLifecycleLog } from '../../src/session-lifecycle-log.js';
|
||||||
import { isOpenCodeAvailable, resolveOpenCodeDir } from '../../src/utils/opencode-cli-resolver.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 mockedReadFile = vi.mocked(fs.readFile);
|
||||||
const mockedWriteFile = vi.mocked(fs.writeFile);
|
const mockedWriteFile = vi.mocked(fs.writeFile);
|
||||||
@@ -90,6 +96,8 @@ const mockedSubagentWatcher = vi.mocked(subagentWatcher);
|
|||||||
const mockedGetLifecycleLog = vi.mocked(getLifecycleLog);
|
const mockedGetLifecycleLog = vi.mocked(getLifecycleLog);
|
||||||
const mockedIsOpenCodeAvailable = vi.mocked(isOpenCodeAvailable);
|
const mockedIsOpenCodeAvailable = vi.mocked(isOpenCodeAvailable);
|
||||||
const mockedResolveOpenCodeDir = vi.mocked(resolveOpenCodeDir);
|
const mockedResolveOpenCodeDir = vi.mocked(resolveOpenCodeDir);
|
||||||
|
const mockedIsGeminiAvailable = vi.mocked(isGeminiAvailable);
|
||||||
|
const mockedResolveGeminiDir = vi.mocked(resolveGeminiDir);
|
||||||
|
|
||||||
describe('system-routes', () => {
|
describe('system-routes', () => {
|
||||||
let harness: RouteTestHarness;
|
let harness: RouteTestHarness;
|
||||||
@@ -117,6 +125,8 @@ describe('system-routes', () => {
|
|||||||
} as never);
|
} as never);
|
||||||
mockedIsOpenCodeAvailable.mockReturnValue(false);
|
mockedIsOpenCodeAvailable.mockReturnValue(false);
|
||||||
mockedResolveOpenCodeDir.mockReturnValue(null);
|
mockedResolveOpenCodeDir.mockReturnValue(null);
|
||||||
|
mockedIsGeminiAvailable.mockReturnValue(false);
|
||||||
|
mockedResolveGeminiDir.mockReturnValue(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -167,7 +177,7 @@ describe('system-routes', () => {
|
|||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
const body = JSON.parse(res.body);
|
const body = JSON.parse(res.body);
|
||||||
expect(body.success).toBe(false);
|
expect(body.message ?? body.error).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -422,7 +432,7 @@ describe('system-routes', () => {
|
|||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
const body = JSON.parse(res.body);
|
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 () => {
|
it('saves lastUsedCase as partial update without overwriting other settings', async () => {
|
||||||
@@ -520,7 +530,7 @@ describe('system-routes', () => {
|
|||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
const body = JSON.parse(res.body);
|
const body = JSON.parse(res.body);
|
||||||
expect(body.success).toBe(false);
|
expect(body.message ?? body.error).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -577,7 +587,7 @@ describe('system-routes', () => {
|
|||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
const body = JSON.parse(res.body);
|
const body = JSON.parse(res.body);
|
||||||
expect(body.success).toBe(false);
|
expect(body.message ?? body.error).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -765,6 +775,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 ==========
|
// ========== GET /api/execution/model-config ==========
|
||||||
|
|
||||||
describe('GET /api/execution/model-config', () => {
|
describe('GET /api/execution/model-config', () => {
|
||||||
|
|||||||
@@ -61,6 +61,16 @@ describe('run mode UI', () => {
|
|||||||
expect(app.runMode).toBe('claude');
|
expect(app.runMode).toBe('claude');
|
||||||
expect(runBtnLabel.textContent).toBe('Run');
|
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', () => {
|
describe('Codex quick start settings', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user