From 58d117e6744e552191f49b0441a6a4758a58ae58 Mon Sep 17 00:00:00 2001 From: VincentShipsIt Date: Fri, 8 May 2026 19:45:20 +0200 Subject: [PATCH 1/7] Add Gemini CLI pipeline provider --- apps/desktop/src/main/index.ts | 2 + .../ipc/register-issue-graph-handlers.test.ts | 8 +- .../src/main/ipc/register-pr-handlers.test.ts | 2 +- .../ipc/register-support-handlers.test.ts | 6 +- .../src/main/ipc/register-support-handlers.ts | 10 +- .../issue-detail/PipelineTab.test.tsx | 1 + .../components/issue-detail/helpers.ts | 20 +- .../components/model-provider-options.tsx | 3 +- .../ProjectPhaseSettingsRow.tsx | 4 +- .../ProjectSettingsLeafTabs.test.tsx | 9 +- .../IntegrationsSettingsSection.tsx | 21 +- .../settings-panel/PhaseModelRow.test.tsx | 22 ++ .../settings-panel/PhaseModelRow.tsx | 5 + .../PipelineSettingsSection.test.tsx | 25 +- .../PipelineSettingsSection.tsx | 8 +- .../terminal-panes/useTerminalPane.test.tsx | 2 +- .../features/project/code-browser.test.tsx | 2 +- packages/agents/src/health-check.test.ts | 46 +++- packages/agents/src/health-check.ts | 67 +++++- packages/agents/src/index.ts | 2 + packages/agents/src/process-manager.ts | 5 +- .../src/providers/gemini-cli-provider.test.ts | 108 +++++++++ .../src/providers/gemini-cli-provider.ts | 219 ++++++++++++++++++ packages/agents/src/providers/index.ts | 1 + .../agents/src/providers/registry.test.ts | 14 +- packages/agents/src/providers/registry.ts | 8 + packages/agents/src/providers/types.ts | 2 +- .../src/pipeline.github-issue.smoke.test.ts | 3 + packages/shared/src/cli-model-capabilities.ts | 41 ++-- packages/shared/src/constants.ts | 9 +- packages/shared/src/model-catalog.test.ts | 13 ++ packages/shared/src/model-catalog.ts | 27 ++- packages/shared/src/model-display.ts | 6 + packages/shared/src/model-resolution.test.ts | 8 +- packages/shared/src/model-resolution.ts | 5 +- packages/shared/src/reasoning-effort.ts | 30 +++ packages/shared/src/types.ts | 13 +- packages/ui/src/kanban-board/types.ts | 2 +- 38 files changed, 702 insertions(+), 77 deletions(-) create mode 100644 packages/agents/src/providers/gemini-cli-provider.test.ts create mode 100644 packages/agents/src/providers/gemini-cli-provider.ts diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 364903df..dcfe77b8 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -38,6 +38,7 @@ import path from 'node:path'; import { createClaudeCliProvider, createCodexCliProvider, + createGeminiCliProvider, createOpenRouterProvider, createProviderRegistry, GhCli, @@ -307,6 +308,7 @@ function createWindow() { const providers = createProviderRegistry({ claude: createClaudeCliProvider(processManager), codex: createCodexCliProvider(processManager), + gemini: createGeminiCliProvider(processManager), openrouter: createOpenRouterProvider({ getApiKey: () => process.env.OPENROUTER_API_KEY, getSettings: () => queries.settings.get(), diff --git a/apps/desktop/src/main/ipc/register-issue-graph-handlers.test.ts b/apps/desktop/src/main/ipc/register-issue-graph-handlers.test.ts index cbccb7df..9f24cfd0 100644 --- a/apps/desktop/src/main/ipc/register-issue-graph-handlers.test.ts +++ b/apps/desktop/src/main/ipc/register-issue-graph-handlers.test.ts @@ -29,7 +29,7 @@ function makeGraph(): ProjectIssueGraph { issueNumber: 1, title: 'One', state: 'open', - pipelineStatus: ISSUE_PIPELINE_STATUS.pending, + pipelineStatus: ISSUE_PIPELINE_STATUS.queued, threadId: 'thread-1', }, { @@ -38,7 +38,7 @@ function makeGraph(): ProjectIssueGraph { issueNumber: 2, title: 'Two', state: 'open', - pipelineStatus: ISSUE_PIPELINE_STATUS.pending, + pipelineStatus: ISSUE_PIPELINE_STATUS.queued, threadId: 'thread-2', }, ], @@ -72,7 +72,7 @@ function makeDeps(graph = makeGraph()) { id: 'issue-2', projectId: 'project-1', issueNumber: 2, - pipelineStatus: ISSUE_PIPELINE_STATUS.pending, + pipelineStatus: ISSUE_PIPELINE_STATUS.queued, threadId: 'thread-2', }, ]; @@ -185,7 +185,7 @@ describe('registerIssueGraphHandlers', () => { threadId: 'thread-2', phase: PIPELINE_PHASE.failed, }); - notifyIssueGraphPipelinePhaseChange({ threadId: 'thread-3', phase: PIPELINE_PHASE.plan }); + notifyIssueGraphPipelinePhaseChange({ threadId: 'thread-3', phase: PIPELINE_PHASE.planning }); }); it('refreshes body-derived dependency edges and skips unknown issue references', () => { diff --git a/apps/desktop/src/main/ipc/register-pr-handlers.test.ts b/apps/desktop/src/main/ipc/register-pr-handlers.test.ts index b7b4a9f4..553ed62f 100644 --- a/apps/desktop/src/main/ipc/register-pr-handlers.test.ts +++ b/apps/desktop/src/main/ipc/register-pr-handlers.test.ts @@ -30,7 +30,7 @@ function makeDeps( getById: vi.fn(() => project), }, githubIssues: { - getByLinkedPrNumber: vi.fn(() => ({ threadId: 'thread-1' })), + getByLinkedPrNumber: vi.fn((): { threadId: string } | null => ({ threadId: 'thread-1' })), }, diffs: { list: vi.fn(() => [{ id: 'diff-1', filePath: 'src/app.ts' }]), diff --git a/apps/desktop/src/main/ipc/register-support-handlers.test.ts b/apps/desktop/src/main/ipc/register-support-handlers.test.ts index c0caab4f..3480143c 100644 --- a/apps/desktop/src/main/ipc/register-support-handlers.test.ts +++ b/apps/desktop/src/main/ipc/register-support-handlers.test.ts @@ -93,7 +93,7 @@ const queries = { const processManager = { on: vi.fn(), get: vi.fn(), -} as never; +}; const notificationService = { listActive: vi.fn(() => []), @@ -132,7 +132,7 @@ beforeEach(() => { ipcMain, mainWindow: mainWindow as never, queries: queries as unknown as never, - processManager, + processManager: processManager as never, pipeline: {} as never, emitter: {} as never, notificationService, @@ -440,7 +440,7 @@ describe('process manager forwarding', () => { ipcMain, mainWindow: destroyedWindow as never, queries: queries as unknown as never, - processManager, + processManager: processManager as never, pipeline: {} as never, emitter: {} as never, notificationService, diff --git a/apps/desktop/src/main/ipc/register-support-handlers.ts b/apps/desktop/src/main/ipc/register-support-handlers.ts index 574ef452..d11d07e6 100644 --- a/apps/desktop/src/main/ipc/register-support-handlers.ts +++ b/apps/desktop/src/main/ipc/register-support-handlers.ts @@ -340,10 +340,16 @@ export function registerSupportHandlers({ if ((state === 'running' || state === 'exited') && proc?.threadId) { const ts = formatClockTime(new Date()); const agentColor = - type === 'claude' ? '\x1b[36m' : type === 'codex' ? '\x1b[33m' : '\x1b[35m'; + type === 'claude' + ? '\x1b[36m' + : type === 'codex' + ? '\x1b[33m' + : type === 'gemini' + ? '\x1b[32m' + : '\x1b[35m'; const exitColor = state === 'exited' ? '\x1b[2m' : ''; const typeLabel = - type === 'claude' || type === 'codex' || type === 'openrouter' + type === 'claude' || type === 'codex' || type === 'gemini' || type === 'openrouter' ? `${providerDisplay(type as ExecutorModel)}${type === 'openrouter' ? '' : ' CLI'}` : type; emitTerminalEvent(proc.threadId, { diff --git a/apps/desktop/src/renderer/components/issue-detail/PipelineTab.test.tsx b/apps/desktop/src/renderer/components/issue-detail/PipelineTab.test.tsx index db1eb12e..421b7cae 100644 --- a/apps/desktop/src/renderer/components/issue-detail/PipelineTab.test.tsx +++ b/apps/desktop/src/renderer/components/issue-detail/PipelineTab.test.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom +import '@testing-library/jest-dom/vitest'; import type { FeatureQaResult, GitHubIssueCacheRecord, Thread } from '@shipcode/shared'; import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; diff --git a/apps/desktop/src/renderer/components/issue-detail/helpers.ts b/apps/desktop/src/renderer/components/issue-detail/helpers.ts index e1d0bfd0..4fedb0db 100644 --- a/apps/desktop/src/renderer/components/issue-detail/helpers.ts +++ b/apps/desktop/src/renderer/components/issue-detail/helpers.ts @@ -5,7 +5,12 @@ import type { ReviewRecord, ShipCodePlan, } from '@shipcode/shared'; -import { PIPELINE_PHASE, shipCodePlanSchema, stripAnsi } from '@shipcode/shared'; +import { + PIPELINE_EXECUTOR_PROVIDERS, + PIPELINE_PHASE, + shipCodePlanSchema, + stripAnsi, +} from '@shipcode/shared'; export { formatRelativeTime as timeAgo } from '@shipcode/shared'; export { stripAnsi }; @@ -35,10 +40,10 @@ export const PHASE_PROVIDER_OPTIONS: Record< 'planner' | 'reviewer' | 'executor' | 'verifier', ExecutorModel[] > = { - planner: ['claude', 'codex', 'openrouter'], - reviewer: ['claude', 'codex', 'openrouter'], - executor: ['claude', 'codex', 'openrouter'], - verifier: ['claude', 'codex', 'openrouter'], + planner: [...PIPELINE_EXECUTOR_PROVIDERS], + reviewer: [...PIPELINE_EXECUTOR_PROVIDERS], + executor: [...PIPELINE_EXECUTOR_PROVIDERS], + verifier: [...PIPELINE_EXECUTOR_PROVIDERS], }; export type PlanStatusBadgeVariant = @@ -116,7 +121,10 @@ export function decodePhaseOption(value: string): { } { const [providerRaw, modelIdRaw] = value.split('::'); const provider = - providerRaw === 'claude' || providerRaw === 'codex' || providerRaw === 'openrouter' + providerRaw === 'claude' || + providerRaw === 'codex' || + providerRaw === 'gemini' || + providerRaw === 'openrouter' ? providerRaw : 'claude'; return { provider, modelId: modelIdRaw === '__default__' ? null : modelIdRaw }; diff --git a/apps/desktop/src/renderer/components/model-provider-options.tsx b/apps/desktop/src/renderer/components/model-provider-options.tsx index 79574d80..18205271 100644 --- a/apps/desktop/src/renderer/components/model-provider-options.tsx +++ b/apps/desktop/src/renderer/components/model-provider-options.tsx @@ -9,6 +9,7 @@ import { export const PROVIDER_DISPLAY: Record = { claude: 'Anthropic', codex: 'OpenAI', + gemini: 'Google', openrouter: 'OpenRouter', }; @@ -16,7 +17,7 @@ export function getModelOptions( provider: ExecutorModel, integrationStatus?: IntegrationStatus, ): ReadonlyArray<{ value: string; label: string }> { - if (provider === 'claude' || provider === 'codex') { + if (provider === 'claude' || provider === 'codex' || provider === 'gemini') { return getCapabilityModelOptions(integrationStatus, provider); } return OPENROUTER_MODEL_OPTIONS; diff --git a/apps/desktop/src/renderer/components/project-settings-modal/ProjectPhaseSettingsRow.tsx b/apps/desktop/src/renderer/components/project-settings-modal/ProjectPhaseSettingsRow.tsx index 8f5bcc6a..2c3750d6 100644 --- a/apps/desktop/src/renderer/components/project-settings-modal/ProjectPhaseSettingsRow.tsx +++ b/apps/desktop/src/renderer/components/project-settings-modal/ProjectPhaseSettingsRow.tsx @@ -41,7 +41,9 @@ import { } from './shared'; function asExecutorModel(value: Project['plannerModelOverride']): ExecutorModel | null { - if (value === 'claude' || value === 'codex' || value === 'openrouter') return value; + if (value === 'claude' || value === 'codex' || value === 'gemini' || value === 'openrouter') { + return value; + } return null; } diff --git a/apps/desktop/src/renderer/components/project-settings-modal/ProjectSettingsLeafTabs.test.tsx b/apps/desktop/src/renderer/components/project-settings-modal/ProjectSettingsLeafTabs.test.tsx index d0a9a12d..d393e835 100644 --- a/apps/desktop/src/renderer/components/project-settings-modal/ProjectSettingsLeafTabs.test.tsx +++ b/apps/desktop/src/renderer/components/project-settings-modal/ProjectSettingsLeafTabs.test.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom +import '@testing-library/jest-dom/vitest'; import type { AppSettings, ContextFileInfo, @@ -7,7 +8,11 @@ import type { Project, ProjectReadinessReport, } from '@shipcode/shared'; -import { DEFAULT_SETTINGS, SHIPCODE_DEFAULT_LABELS } from '@shipcode/shared'; +import { + DEFAULT_SETTINGS, + PIPELINE_EXECUTOR_PROVIDERS, + SHIPCODE_DEFAULT_LABELS, +} from '@shipcode/shared'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { renderWithQueryClient } from '../../test/render'; @@ -604,6 +609,8 @@ describe('project settings leaf tabs', () => { expect(screen.getByText('Planner')).toBeInTheDocument(); expect(screen.getByText('Verifier')).toBeInTheDocument(); + expect(PIPELINE_EXECUTOR_PROVIDERS).toContain('gemini'); + fireEvent.pointerDown(screen.getByRole('button', { name: 'Apply Preset' })); fireEvent.click(screen.getByText('Claude')); fireEvent.pointerDown(screen.getByRole('button', { name: 'Apply Preset' })); diff --git a/apps/desktop/src/renderer/components/settings-panel/IntegrationsSettingsSection.tsx b/apps/desktop/src/renderer/components/settings-panel/IntegrationsSettingsSection.tsx index 95d0ab90..cae1f811 100644 --- a/apps/desktop/src/renderer/components/settings-panel/IntegrationsSettingsSection.tsx +++ b/apps/desktop/src/renderer/components/settings-panel/IntegrationsSettingsSection.tsx @@ -63,6 +63,13 @@ export function IntegrationsSettingsSection({ }; const getCliVersionLine = (version: string | null) => version?.split('\n')[0]?.trim() ?? null; + const missingCli = { + available: false, + version: null, + path: null, + error: null, + authenticated: false, + }; const projectOpenTargets: ProjectOpenTarget[] = [ 'cursor', 'finder', @@ -237,6 +244,7 @@ export function IntegrationsSettingsSection({ {[ { key: 'claude', label: 'Claude CLI' }, { key: 'codex', label: 'Codex CLI' }, + { key: 'gemini', label: 'Gemini CLI' }, { key: 'gh', label: 'GitHub CLI' }, ].map(({ key, label }) => { const cli = @@ -244,7 +252,9 @@ export function IntegrationsSettingsSection({ ? integrationStatus.system.claude : key === 'codex' ? integrationStatus.system.codex - : integrationStatus.system.gh; + : key === 'gemini' + ? (integrationStatus.system.gemini ?? missingCli) + : integrationStatus.system.gh; const ghScope = key === 'gh' ? integrationStatus.ghAuth.hasProjectScope === true @@ -254,9 +264,14 @@ export function IntegrationsSettingsSection({ : null : null; const versionLine = getCliVersionLine(cli.version); - const Icon = key === 'claude' ? Sparkles : key === 'codex' ? Terminal : FolderGit; + const Icon = + key === 'claude' || key === 'gemini' + ? Sparkles + : key === 'codex' + ? Terminal + : FolderGit; const modelCapabilities = - key === 'claude' || key === 'codex' + key === 'claude' || key === 'codex' || key === 'gemini' ? integrationStatus.modelCapabilities?.[key] : null; diff --git a/apps/desktop/src/renderer/components/settings-panel/PhaseModelRow.test.tsx b/apps/desktop/src/renderer/components/settings-panel/PhaseModelRow.test.tsx index 51e2d9f7..303b5ae5 100644 --- a/apps/desktop/src/renderer/components/settings-panel/PhaseModelRow.test.tsx +++ b/apps/desktop/src/renderer/components/settings-panel/PhaseModelRow.test.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom +import '@testing-library/jest-dom/vitest'; import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { PhaseModelRow } from './PhaseModelRow'; @@ -104,4 +105,25 @@ describe('PhaseModelRow', () => { expect(screen.getByText('High')).toBeInTheDocument(); expect(screen.queryByText('xhigh')).not.toBeInTheDocument(); }); + + it('renders Gemini as a CLI provider with standard reasoning controls', () => { + render( + , + ); + + expect(screen.getByText('Google')).toBeInTheDocument(); + expect(screen.getByText('Reasoning effort')).toBeInTheDocument(); + expect(screen.queryByText('Custom OpenRouter slug')).not.toBeInTheDocument(); + }); }); diff --git a/apps/desktop/src/renderer/components/settings-panel/PhaseModelRow.tsx b/apps/desktop/src/renderer/components/settings-panel/PhaseModelRow.tsx index fbac0338..41c744f9 100644 --- a/apps/desktop/src/renderer/components/settings-panel/PhaseModelRow.tsx +++ b/apps/desktop/src/renderer/components/settings-panel/PhaseModelRow.tsx @@ -87,6 +87,11 @@ export function PhaseModelRow({ OpenAI{disabledProviders?.codex ? ` (${disabledProviders.codex})` : ''} )} + {validProviders.includes('gemini') && ( + + Google{disabledProviders?.gemini ? ` (${disabledProviders.gemini})` : ''} + + )} {validProviders.includes('openrouter') && ( OpenRouter diff --git a/apps/desktop/src/renderer/components/settings-panel/PipelineSettingsSection.test.tsx b/apps/desktop/src/renderer/components/settings-panel/PipelineSettingsSection.test.tsx index 0d127ea8..084b1c6b 100644 --- a/apps/desktop/src/renderer/components/settings-panel/PipelineSettingsSection.test.tsx +++ b/apps/desktop/src/renderer/components/settings-panel/PipelineSettingsSection.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import type { AppSettings, IntegrationStatus } from '@shipcode/shared'; -import { DEFAULT_SETTINGS } from '@shipcode/shared'; +import { DEFAULT_SETTINGS, PIPELINE_EXECUTOR_PROVIDERS } from '@shipcode/shared'; import '@testing-library/jest-dom/vitest'; import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -27,6 +27,13 @@ const integrationStatus: IntegrationStatus = { error: null, authenticated: true, }, + gemini: { + available: true, + version: '0.1.0', + path: '/usr/local/bin/gemini', + error: null, + authenticated: true, + }, git: { available: true, version: 'git version 2.43.0', @@ -210,4 +217,20 @@ describe('PipelineSettingsSection', () => { }), ); }); + + it('shows Gemini in global phase provider controls', () => { + render( + , + ); + + const modelsTab = screen.getByRole('tab', { name: 'Models' }); + fireEvent.mouseDown(modelsTab, { button: 0 }); + fireEvent.click(modelsTab); + expect(PIPELINE_EXECUTOR_PROVIDERS).toContain('gemini'); + expect(screen.getByText('Pipeline phases')).toBeInTheDocument(); + }); }); diff --git a/apps/desktop/src/renderer/components/settings-panel/PipelineSettingsSection.tsx b/apps/desktop/src/renderer/components/settings-panel/PipelineSettingsSection.tsx index e9c07558..abb68865 100644 --- a/apps/desktop/src/renderer/components/settings-panel/PipelineSettingsSection.tsx +++ b/apps/desktop/src/renderer/components/settings-panel/PipelineSettingsSection.tsx @@ -558,7 +558,7 @@ export function PipelineSettingsSection({ : null } reasoningEffortValue={settings.plannerReasoningEffort} - validProviders={['claude', 'codex', 'openrouter']} + validProviders={['claude', 'codex', 'gemini', 'openrouter']} onModelChange={(value) => onUpdate({ plannerModel: value as AppSettings['plannerModel'], @@ -606,7 +606,7 @@ export function PipelineSettingsSection({ : null } reasoningEffortValue={settings.reviewerReasoningEffort} - validProviders={['claude', 'codex', 'openrouter']} + validProviders={['claude', 'codex', 'gemini', 'openrouter']} onModelChange={(value) => onUpdate({ reviewerModel: value as AppSettings['reviewerModel'], @@ -654,7 +654,7 @@ export function PipelineSettingsSection({ : null } reasoningEffortValue={settings.executorReasoningEffort} - validProviders={['claude', 'codex', 'openrouter']} + validProviders={['claude', 'codex', 'gemini', 'openrouter']} onModelChange={(value) => onUpdate({ executorModel: value as AppSettings['executorModel'], @@ -702,7 +702,7 @@ export function PipelineSettingsSection({ : null } reasoningEffortValue={settings.verifierReasoningEffort} - validProviders={['claude', 'codex', 'openrouter']} + validProviders={['claude', 'codex', 'gemini', 'openrouter']} onModelChange={(value) => onUpdate({ verifierModel: value as AppSettings['verifierModel'], diff --git a/apps/desktop/src/renderer/components/terminal-panes/useTerminalPane.test.tsx b/apps/desktop/src/renderer/components/terminal-panes/useTerminalPane.test.tsx index 4a5d27ad..ac66c78f 100644 --- a/apps/desktop/src/renderer/components/terminal-panes/useTerminalPane.test.tsx +++ b/apps/desktop/src/renderer/components/terminal-panes/useTerminalPane.test.tsx @@ -80,7 +80,7 @@ class MockResizeObserver { function TerminalPaneHarness({ threadId = 'thread-1', - mode = 'history', + mode = 'replay', isRunning = false, }: { threadId?: string; diff --git a/apps/desktop/src/renderer/features/project/code-browser.test.tsx b/apps/desktop/src/renderer/features/project/code-browser.test.tsx index 7d2ce1ad..b07e9eaa 100644 --- a/apps/desktop/src/renderer/features/project/code-browser.test.tsx +++ b/apps/desktop/src/renderer/features/project/code-browser.test.tsx @@ -128,7 +128,7 @@ describe('CodeBrowser', () => { fileEntry({ name: 'package.json', relativePath: 'package.json', isModified: true }), ]; } - if (channel === 'code:list-tree' && args.relativePath === 'src') { + if (channel === 'code:list-tree' && args?.relativePath === 'src') { return [ fileEntry(), fileEntry({ name: 'styles.css', relativePath: 'src/styles.css' }), diff --git a/packages/agents/src/health-check.test.ts b/packages/agents/src/health-check.test.ts index 8a306c95..d0e8f5fb 100644 --- a/packages/agents/src/health-check.test.ts +++ b/packages/agents/src/health-check.test.ts @@ -29,6 +29,8 @@ import { checkCliProviderUsage, checkCodexAuth, checkDesktopApps, + checkGeminiAuth, + checkGeminiModelCapabilities, checkGhAuth, checkIntegrationStatus, checkOpenRouterAuth, @@ -415,11 +417,12 @@ describe('parseGhProjectScope', () => { // checkSystemHealth // --------------------------------------------------------------------------- describe('checkSystemHealth', () => { - it('returns all 4 CLIs with correct availability', async () => { + it('returns provider CLIs with correct availability', async () => { execRouted({ which: { stdout: '/usr/local/bin/tool\n' }, 'claude --version': { stdout: 'claude 1.0.0' }, 'codex --version': { stdout: 'codex 0.1.0' }, + 'gemini --version': { stdout: '0.1.0' }, 'git --version': { stdout: 'git version 2.43.0' }, 'gh --version': { stdout: 'gh version 2.40.1 (2024-01-01)' }, }); @@ -427,6 +430,7 @@ describe('checkSystemHealth', () => { const result = await checkSystemHealth(); expect(result.claude.available).toBe(true); expect(result.codex.available).toBe(true); + expect(result.gemini?.available).toBe(true); expect(result.git.available).toBe(true); expect(result.gh.available).toBe(true); }); @@ -437,10 +441,14 @@ describe('checkSystemHealth', () => { cb = opts; opts = {}; } - // git and gh are available, claude and codex are not + // git and gh are available, provider CLIs are not if (cmd.includes('which git') || cmd.includes('which gh')) { (cb as ExecCallback)(null, { stdout: '/usr/bin/tool', stderr: '' }); - } else if (cmd.includes('which claude') || cmd.includes('which codex')) { + } else if ( + cmd.includes('which claude') || + cmd.includes('which codex') || + cmd.includes('which gemini') + ) { (cb as ExecCallback)(new Error('not found')); } else if (cmd.startsWith('git') || cmd.startsWith('gh')) { (cb as ExecCallback)(null, { stdout: 'version info', stderr: '' }); @@ -452,6 +460,7 @@ describe('checkSystemHealth', () => { const result = await checkSystemHealth(); expect(result.claude.available).toBe(false); expect(result.codex.available).toBe(false); + expect(result.gemini?.available).toBe(false); expect(result.git.available).toBe(true); expect(result.gh.available).toBe(true); }); @@ -474,6 +483,8 @@ describe('checkSystemHealthWithAuth', () => { (cb as ExecCallback)(null, { stdout: 'Authenticated', stderr: '' }); } else if (cmd.includes('printenv OPENAI_API_KEY')) { (cb as ExecCallback)(null, { stdout: 'sk-key', stderr: '' }); + } else if (cmd.includes('printenv GEMINI_API_KEY')) { + (cb as ExecCallback)(null, { stdout: 'gemini-key', stderr: '' }); } else { (cb as ExecCallback)(null, { stdout: 'version 1.0', stderr: '' }); } @@ -484,6 +495,27 @@ describe('checkSystemHealthWithAuth', () => { expect(result.claude.authenticated).toBe(true); expect(result.codex.available).toBe(true); expect(result.codex.authenticated).toBe(true); + expect(result.gemini?.available).toBe(true); + expect(result.gemini?.authenticated).toBe(true); + }); + + it('detects Gemini auth from environment', async () => { + execRouted({ + 'printenv GEMINI_API_KEY': { stdout: 'gemini-key\n' }, + }); + + await expect(checkGeminiAuth()).resolves.toBe(true); + }); + + it('uses Gemini fallback model capabilities when the CLI is reachable', async () => { + execRouted({ + 'gemini --help': { stdout: 'Usage: gemini' }, + }); + + const result = await checkGeminiModelCapabilities(); + expect(result.provider).toBe('gemini'); + expect(result.source).toBe('fallback'); + expect(result.models.map((model) => model.value)).toContain('gemini-2.5-pro'); }); it('sets authenticated=false when CLI available but auth fails', async () => { @@ -1076,13 +1108,17 @@ describe('checkIntegrationStatus', () => { execRouted({ 'which claude': { stdout: '/usr/local/bin/claude\n' }, 'which codex': { stdout: '/usr/local/bin/codex\n' }, + 'which gemini': { stdout: '/usr/local/bin/gemini\n' }, 'which git': { stdout: '/usr/bin/git\n' }, 'which gh': { stdout: '/usr/local/bin/gh\n' }, 'claude --version': { stdout: 'claude 1.0.0' }, 'codex --version': { stdout: 'codex 0.1.0' }, + 'gemini --version': { stdout: '0.1.0' }, + 'gemini --help': { stdout: 'Usage: gemini' }, 'git --version': { stdout: 'git version 2.43.0' }, 'gh --version': { stdout: 'gh version 2.40.1' }, 'claude auth status': { stdout: 'Authenticated' }, + 'printenv GEMINI_API_KEY': { stdout: 'gemini-key\n' }, 'printenv OPENAI_API_KEY': { stdout: 'sk-key\n' }, 'printenv OPENROUTER_API_KEY': { stdout: 'or-key\n' }, 'gh auth status': { @@ -1101,6 +1137,10 @@ describe('checkIntegrationStatus', () => { const result = await checkIntegrationStatus(settings()); expect(result.system.claude.authenticated).toBe(true); expect(result.system.codex.authenticated).toBe(true); + expect(result.system.gemini?.authenticated).toBe(true); + expect(result.modelCapabilities?.gemini.models.map((model) => model.value)).toContain( + 'gemini-2.5-pro', + ); expect(result.ghAuth.authenticated).toBe(true); expect(result.openrouter.authStatus).toBe('valid'); expect(result.discord.validationStatus).toBe('missing'); diff --git a/packages/agents/src/health-check.ts b/packages/agents/src/health-check.ts index f3f7fce1..b45633b5 100644 --- a/packages/agents/src/health-check.ts +++ b/packages/agents/src/health-check.ts @@ -151,10 +151,11 @@ let systemHealthInFlight: Promise | null = null; let systemHealthWithAuthCache: TimedCacheEntry | null = null; let systemHealthWithAuthInFlight: Promise | null = null; let cliModelCapabilitiesCache: TimedCacheEntry< - Record<'claude' | 'codex', CliModelCapabilities> + Record<'claude' | 'codex' | 'gemini', CliModelCapabilities> +> | null = null; +let cliModelCapabilitiesInFlight: Promise< + Record<'claude' | 'codex' | 'gemini', CliModelCapabilities> > | null = null; -let cliModelCapabilitiesInFlight: Promise> | null = - null; const providerUsageCache = new Map< CliProviderUsageProvider, TimedCacheEntry @@ -171,6 +172,14 @@ const DESKTOP_APP_LABELS: Record = { t3code: 'T3 Code', }; +const MISSING_CLI_HEALTH: CliHealth = { + available: false, + version: null, + path: null, + error: null, + authenticated: false, +}; + async function checkCli(command: string, versionFlag: string = '--version'): Promise { const env = shellExecEnv(); try { @@ -1115,6 +1124,19 @@ export async function checkCodexAuth(): Promise { return fileExists(codexAuthPath); } +export async function checkGeminiAuth(): Promise { + if ((await readEnvVar('GEMINI_API_KEY')) || (await readEnvVar('GOOGLE_API_KEY'))) { + return true; + } + + try { + await execAsync('gemini auth status', { timeout: 10_000, env: shellExecEnv() }); + return true; + } catch { + return false; + } +} + export type OpenRouterAuthStatus = | { ok: true; label?: string } | { @@ -1437,14 +1459,15 @@ export async function checkSystemHealth(options: CacheOptions = {}): Promise { - const [claude, codex, git, gh] = await Promise.all([ + const [claude, codex, gemini, git, gh] = await Promise.all([ checkCli('claude', '--version'), checkCli('codex', '--version'), + checkCli('gemini', '--version'), checkCli('git', '--version'), checkCli('gh', '--version'), ]); - const result = { claude, codex, git, gh }; + const result = { claude, codex, gemini, git, gh }; systemHealthCache = createTimedCacheEntry(result); return result; })(); @@ -1467,16 +1490,19 @@ export async function checkSystemHealthWithAuth(options: CacheOptions = {}): Pro } systemHealthWithAuthInFlight = (async () => { - const [health, claudeAuth, codexAuth] = await Promise.all([ + const [health, claudeAuth, codexAuth, geminiAuth] = await Promise.all([ checkSystemHealth(options), checkClaudeAuth(), checkCodexAuth(), + checkGeminiAuth(), ]); - const result = { + const gemini = health.gemini ?? MISSING_CLI_HEALTH; + const result: SystemHealth = { ...health, claude: { ...health.claude, authenticated: health.claude.available && claudeAuth }, codex: { ...health.codex, authenticated: health.codex.available && codexAuth }, + gemini: { ...gemini, authenticated: gemini.available && geminiAuth }, }; systemHealthWithAuthCache = createTimedCacheEntry(result); return result; @@ -1588,19 +1614,40 @@ export async function checkClaudeModelCapabilities(): Promise { + const checkedAt = new Date().toISOString(); + try { + await execAsync('gemini --help', { + timeout: CLI_MODEL_CATALOG_TIMEOUT_MS, + maxBuffer: 512_000, + env: shellExecEnv(), + }); + return fallbackCliModelCapabilities('gemini', checkedAt); + } catch (error) { + return { + provider: 'gemini', + source: 'unavailable', + models: [], + error: `Gemini CLI unavailable: ${summarizeExecFailure(error)}`, + checkedAt, + }; + } +} + export async function checkCliModelCapabilities( options: CacheOptions = {}, -): Promise> { +): Promise> { const cached = getFreshCachedValue(cliModelCapabilitiesCache, CLI_MODEL_CAPABILITIES_TTL_MS); if (!options.force && cached) return cached; if (cliModelCapabilitiesInFlight) return cliModelCapabilitiesInFlight; cliModelCapabilitiesInFlight = (async () => { - const [claude, codex] = await Promise.all([ + const [claude, codex, gemini] = await Promise.all([ checkClaudeModelCapabilities(), checkCodexModelCapabilities(), + checkGeminiModelCapabilities(), ]); - const result = { claude, codex }; + const result = { claude, codex, gemini }; cliModelCapabilitiesCache = createTimedCacheEntry(result); return result; })(); diff --git a/packages/agents/src/index.ts b/packages/agents/src/index.ts index a60ce2e5..c1319926 100644 --- a/packages/agents/src/index.ts +++ b/packages/agents/src/index.ts @@ -58,6 +58,8 @@ export { checkCodexAuth, checkCodexModelCapabilities, checkDesktopApps, + checkGeminiAuth, + checkGeminiModelCapabilities, checkGhAuth, checkIntegrationStatus, checkOpenRouterAuth, diff --git a/packages/agents/src/process-manager.ts b/packages/agents/src/process-manager.ts index 27702d46..523ecb3e 100644 --- a/packages/agents/src/process-manager.ts +++ b/packages/agents/src/process-manager.ts @@ -6,7 +6,7 @@ import { assertWorkspaceSafe } from '@shipcode/shared/worktree-path'; import { nanoid } from 'nanoid'; import * as pty from 'node-pty'; -const ALLOWED_COMMANDS = new Set(['claude', 'codex', 'gh']); +const ALLOWED_COMMANDS = new Set(['claude', 'codex', 'gemini', 'gh']); const TRUSTED_SHELLS = new Set([ '/bin/bash', '/bin/zsh', @@ -33,6 +33,9 @@ const SAFE_ENV_KEYS = new Set([ 'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'OPENROUTER_API_KEY', + 'GEMINI_API_KEY', + 'GOOGLE_API_KEY', + 'GOOGLE_APPLICATION_CREDENTIALS', ]); function filterEnv(env: Record): Record { diff --git a/packages/agents/src/providers/gemini-cli-provider.test.ts b/packages/agents/src/providers/gemini-cli-provider.test.ts new file mode 100644 index 00000000..63bce659 --- /dev/null +++ b/packages/agents/src/providers/gemini-cli-provider.test.ts @@ -0,0 +1,108 @@ +import { EventEmitter } from 'node:events'; +import { describe, expect, it, vi } from 'vitest'; +import type { ProcessManager } from '../process-manager'; +import { _internals, createGeminiCliProvider } from './gemini-cli-provider'; +import type { ProviderRequest } from './types'; + +function req(overrides: Partial = {}): ProviderRequest { + return { + phase: 'plan', + prompt: 'PROMPT', + cwd: '/tmp/wt', + projectPath: '/tmp/proj', + signal: new AbortController().signal, + threadId: 't1', + ...overrides, + }; +} + +class FakeProcessManager extends EventEmitter { + spawnWithStdin = vi.fn( + ( + _type: string, + _command: string, + _args: string[], + _cwd: string, + _input: string, + _threadId?: string, + ) => ({ id: 'proc-1' }), + ); + spawn = vi.fn(); + kill = vi.fn(); +} + +describe('gemini-cli-provider', () => { + it('builds headless JSON args and keeps the prompt in stdin', () => { + expect( + _internals.buildGeminiCommand(req({ phase: 'execute', modelHint: 'gemini-2.5-pro' })), + ).toEqual({ + args: [ + '-p', + '-', + '--output-format', + 'json', + '-m', + 'gemini-2.5-pro', + '--approval-mode', + 'never', + '--sandbox', + 'workspace-write', + ], + stdin: 'PROMPT', + }); + }); + + it('parses JSON text and resolved model when Gemini reports one', () => { + expect( + _internals.parseGeminiOutput( + JSON.stringify({ response: 'done', model: 'gemini-2.5-pro', usage: { totalTokens: 9 } }), + ), + ).toEqual({ text: 'done', resolvedModel: 'gemini-2.5-pro' }); + }); + + it('falls back to plain text and omits unstable telemetry', async () => { + const processManager = new FakeProcessManager(); + const provider = createGeminiCliProvider(processManager as unknown as ProcessManager); + const resultPromise = provider.generate(req()); + + processManager.emit('output', 'proc-1', JSON.stringify({ response: 'done' })); + processManager.emit('exit', 'proc-1', 0); + + const result = await resultPromise; + expect(result.rawOutput).toBe('done'); + expect(result.resolvedModel).toBeUndefined(); + expect(result.tokensUsed).toBeUndefined(); + expect(result.costUsd).toBeUndefined(); + }); + + it('clamps failures without leaking later stderr lines', async () => { + const processManager = new FakeProcessManager(); + const provider = createGeminiCliProvider(processManager as unknown as ProcessManager); + const resultPromise = provider.generate(req()); + + processManager.emit('output', 'proc-1', 'Auth failed\nPROMPT\n'.repeat(50)); + processManager.emit('exit', 'proc-1', 1); + + const result = await resultPromise; + expect(result.providerError?.message).toBe('Auth failed'); + expect(result.providerError?.message).not.toContain('PROMPT'); + }); + + it('emits canonical lifecycle, raw, and done terminal events', async () => { + const processManager = new FakeProcessManager(); + const onTerminalEvent = vi.fn(); + const provider = createGeminiCliProvider(processManager as unknown as ProcessManager); + const resultPromise = provider.generate(req({ onTerminalEvent })); + + processManager.emit('output', 'proc-1', '{"response":"done"}'); + processManager.emit('exit', 'proc-1', 0); + await resultPromise; + + expect(onTerminalEvent).toHaveBeenCalledWith({ + kind: 'lifecycle', + message: 'Gemini CLI started', + }); + expect(onTerminalEvent).toHaveBeenCalledWith({ kind: 'raw', content: '{"response":"done"}' }); + expect(onTerminalEvent).toHaveBeenCalledWith({ kind: 'done' }); + }); +}); diff --git a/packages/agents/src/providers/gemini-cli-provider.ts b/packages/agents/src/providers/gemini-cli-provider.ts new file mode 100644 index 00000000..52412c76 --- /dev/null +++ b/packages/agents/src/providers/gemini-cli-provider.ts @@ -0,0 +1,219 @@ +import type { ProcessManager } from '../process-manager'; +import { measurePromptPayload } from '../prompt-scope'; +import type { AgentProvider, ProviderPhase, ProviderRequest, ProviderResponse } from './types'; + +interface GeminiCommand { + args: string[]; + stdin: string; +} + +interface GeminiRunResult { + rawOutput: string; + exitCode: number; +} + +type ProcessManagerWithStdin = ProcessManager & { + spawnWithStdin?: ( + type: 'gemini', + command: string, + args: string[], + cwd: string, + input: string, + threadId?: string, + options?: Parameters[5], + ) => ReturnType; +}; + +function stripAnsi(value: string): string { + return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), ''); +} + +function buildGeminiCommand(req: ProviderRequest): GeminiCommand { + const args = ['-p', '-', '--output-format', 'json']; + if (req.modelHint) args.push('-m', req.modelHint); + if (req.phase !== 'execute') { + args.push('--approval-mode', 'never', '--sandbox', 'read-only'); + } else { + args.push('--approval-mode', 'never', '--sandbox', 'workspace-write'); + } + return { args, stdin: req.prompt }; +} + +async function runGeminiCli( + processManager: ProcessManager, + command: GeminiCommand, + req: ProviderRequest, +): Promise { + if (req.signal.aborted) return { rawOutput: '', exitCode: 130 }; + + let process: ReturnType; + try { + const options = req.workspaceRoot !== undefined ? { workspaceRoot: req.workspaceRoot } : {}; + const processManagerWithStdin = processManager as ProcessManagerWithStdin; + if (processManagerWithStdin.spawnWithStdin) { + process = processManagerWithStdin.spawnWithStdin( + 'gemini', + 'gemini', + command.args, + req.cwd, + command.stdin, + req.threadId, + options, + ); + } else { + process = processManager.spawn( + 'gemini', + 'gemini', + ['-p', command.stdin, ...command.args.slice(2)], + req.cwd, + req.threadId, + options, + ); + } + } catch (err) { + return { rawOutput: err instanceof Error ? err.message : String(err), exitCode: 127 }; + } + + req.onTerminalEvent?.({ kind: 'lifecycle', message: 'Gemini CLI started' }); + + return new Promise((resolve) => { + let rawOutput = ''; + let settled = false; + + const cleanup = () => { + processManager.removeListener('output', outputHandler); + processManager.removeListener('exit', exitHandler); + req.signal.removeEventListener('abort', abortHandler); + }; + + const settle = (result: GeminiRunResult) => { + if (settled) return; + settled = true; + cleanup(); + resolve(result); + }; + + const outputHandler = (processId: string, data: string) => { + if (processId !== process.id) return; + rawOutput += data; + req.onTerminalEvent?.({ kind: 'raw', content: data }); + }; + + const exitHandler = (processId: string, exitCode: number) => { + if (processId !== process.id) return; + req.onTerminalEvent?.({ kind: 'done' }); + settle({ rawOutput, exitCode }); + }; + + const abortHandler = () => { + try { + processManager.kill(process.id); + } catch { + // Exit handler owns settlement when the process is already gone. + } + setTimeout(() => { + if (!settled) settle({ rawOutput, exitCode: 130 }); + }, 2000); + }; + + processManager.on('output', outputHandler); + processManager.on('exit', exitHandler); + req.signal.addEventListener('abort', abortHandler, { once: true }); + }); +} + +function firstString(...values: unknown[]): string | null { + for (const value of values) { + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return null; +} + +function parseGeminiOutput(rawOutput: string): { text: string; resolvedModel?: string } { + const cleaned = stripAnsi(rawOutput).trim(); + if (!cleaned) return { text: '' }; + + try { + const parsed = JSON.parse(cleaned) as Record; + const candidate = Array.isArray(parsed.candidates) + ? (parsed.candidates[0] as Record | undefined) + : undefined; + const content = candidate?.content as Record | undefined; + const parts = Array.isArray(content?.parts) ? content.parts : []; + const partsText = parts + .map((part) => + typeof (part as { text?: unknown }).text === 'string' + ? (part as { text: string }).text + : null, + ) + .filter((value): value is string => !!value) + .join('\n'); + const text = + firstString(parsed.response, parsed.text, parsed.content, parsed.output, partsText) ?? + cleaned; + const resolvedModel = firstString(parsed.model, parsed.modelId, parsed.resolvedModel); + return resolvedModel ? { text, resolvedModel } : { text }; + } catch { + return { text: cleaned }; + } +} + +function clampGeminiFailure(rawOutput: string): string { + const line = stripAnsi(rawOutput) + .split('\n') + .map((entry) => entry.trim()) + .find(Boolean); + return (line ?? 'Gemini CLI failed').slice(0, 280); +} + +export function createGeminiCliProvider(processManager: ProcessManager): AgentProvider { + return { + id: 'gemini-cli', + supports: new Set(['plan', 'review', 'revision', 'verify', 'execute']), + async generate(req: ProviderRequest): Promise { + const command = buildGeminiCommand(req); + const result = await runGeminiCli(processManager, command, req); + const parsed = parseGeminiOutput(result.rawOutput); + const promptTelemetry = { + phase: req.phase, + promptSize: measurePromptPayload(req.prompt), + ...(req.promptMaterialSummary ? { selectedMaterials: req.promptMaterialSummary } : {}), + }; + + return { + rawOutput: parsed.text, + exitCode: result.exitCode, + promptTelemetry, + ...(parsed.resolvedModel ? { resolvedModel: parsed.resolvedModel } : {}), + ...(result.exitCode === 127 + ? { + providerError: { + kind: 'binary_missing' as const, + message: 'gemini CLI not found on PATH', + retryable: false, + }, + } + : result.exitCode === 130 + ? { providerError: { kind: 'aborted' as const, message: 'aborted', retryable: false } } + : result.exitCode !== 0 + ? { + providerError: { + kind: 'unknown' as const, + message: clampGeminiFailure(result.rawOutput), + retryable: true, + }, + } + : {}), + }; + }, + async healthCheck() { + return { ok: true }; + }, + }; +} + +export const _internals = { + buildGeminiCommand, + parseGeminiOutput, + clampGeminiFailure, +}; diff --git a/packages/agents/src/providers/index.ts b/packages/agents/src/providers/index.ts index a483eeb1..fd6cc279 100644 --- a/packages/agents/src/providers/index.ts +++ b/packages/agents/src/providers/index.ts @@ -1,4 +1,5 @@ export { createClaudeCliProvider, createCodexCliProvider } from './cli-provider'; +export { createGeminiCliProvider } from './gemini-cli-provider'; export type { OpenRouterChatMessage, OpenRouterChatRequest, diff --git a/packages/agents/src/providers/registry.test.ts b/packages/agents/src/providers/registry.test.ts index 6820d6ed..435ad855 100644 --- a/packages/agents/src/providers/registry.test.ts +++ b/packages/agents/src/providers/registry.test.ts @@ -14,6 +14,7 @@ function makeProvider(id: AgentProvider['id'], phases: ProviderPhase[]): AgentPr describe('createProviderRegistry', () => { const claude = makeProvider('claude-cli', ['plan', 'review', 'revision', 'verify', 'execute']); const codex = makeProvider('codex-cli', ['plan', 'review', 'revision', 'verify', 'execute']); + const gemini = makeProvider('gemini-cli', ['plan', 'review', 'revision', 'verify', 'execute']); const openrouter = makeProvider('openrouter', [ 'plan', 'review', @@ -21,7 +22,7 @@ describe('createProviderRegistry', () => { 'verify', 'execute', ]); - const registry = createProviderRegistry({ claude, codex, openrouter }); + const registry = createProviderRegistry({ claude, codex, gemini, openrouter }); it('dispatches claude agent to claude-cli provider', () => { expect(registry.for('claude', 'plan')).toBe(claude); @@ -36,6 +37,14 @@ describe('createProviderRegistry', () => { expect(registry.for('codex', 'execute')).toBe(codex); }); + it('dispatches gemini agent to gemini-cli provider', () => { + expect(registry.for('gemini', 'plan')).toBe(gemini); + expect(registry.for('gemini', 'review')).toBe(gemini); + expect(registry.for('gemini', 'revision')).toBe(gemini); + expect(registry.for('gemini', 'verify')).toBe(gemini); + expect(registry.for('gemini', 'execute')).toBe(gemini); + }); + it('dispatches openrouter agent to openrouter provider', () => { expect(registry.for('openrouter', 'plan')).toBe(openrouter); expect(registry.for('openrouter', 'verify')).toBe(openrouter); @@ -50,7 +59,8 @@ describe('createProviderRegistry', () => { const all = registry.all(); expect(all.get('claude-cli')).toBe(claude); expect(all.get('codex-cli')).toBe(codex); + expect(all.get('gemini-cli')).toBe(gemini); expect(all.get('openrouter')).toBe(openrouter); - expect(all.size).toBe(3); + expect(all.size).toBe(4); }); }); diff --git a/packages/agents/src/providers/registry.ts b/packages/agents/src/providers/registry.ts index d23461ac..f780fb72 100644 --- a/packages/agents/src/providers/registry.ts +++ b/packages/agents/src/providers/registry.ts @@ -10,6 +10,7 @@ * Tier 1 rules: * - agent 'claude' → claude-cli provider (all phases) * - agent 'codex' → codex-cli provider (all phases) + * - agent 'gemini' → gemini-cli provider (all phases) * - agent 'openrouter' → openrouter provider (plan/review/revision/verify only) * - agent 'gh' is not an LLM agent and is not handled here * @@ -24,6 +25,7 @@ import type { AgentProvider, ProviderPhase, ProviderRegistry } from './types'; export interface RegistryProviders { claude: AgentProvider; codex: AgentProvider; + gemini?: AgentProvider; openrouter: AgentProvider; } @@ -33,6 +35,7 @@ export function createProviderRegistry(providers: RegistryProviders): ProviderRe [providers.codex.id, providers.codex], [providers.openrouter.id, providers.openrouter], ]); + if (providers.gemini) byId.set(providers.gemini.id, providers.gemini); function forAgent(agent: AgentType): AgentProvider { switch (agent) { @@ -40,6 +43,11 @@ export function createProviderRegistry(providers: RegistryProviders): ProviderRe return providers.claude; case 'codex': return providers.codex; + case 'gemini': + if (!providers.gemini) { + throw new Error("ProviderRegistry: provider for agent 'gemini' is not registered"); + } + return providers.gemini; case 'openrouter': return providers.openrouter; case 'gh': diff --git a/packages/agents/src/providers/types.ts b/packages/agents/src/providers/types.ts index f21230cb..1bb60a29 100644 --- a/packages/agents/src/providers/types.ts +++ b/packages/agents/src/providers/types.ts @@ -152,7 +152,7 @@ export interface ProviderResponse { } export interface AgentProvider { - readonly id: 'claude-cli' | 'codex-cli' | 'openrouter'; + readonly id: 'claude-cli' | 'codex-cli' | 'gemini-cli' | 'openrouter'; readonly supports: ReadonlySet; generate(req: ProviderRequest): Promise; healthCheck(): Promise<{ ok: boolean; reason?: string }>; diff --git a/packages/pipeline/src/pipeline.github-issue.smoke.test.ts b/packages/pipeline/src/pipeline.github-issue.smoke.test.ts index 6fd7204e..84415fc1 100644 --- a/packages/pipeline/src/pipeline.github-issue.smoke.test.ts +++ b/packages/pipeline/src/pipeline.github-issue.smoke.test.ts @@ -12,6 +12,7 @@ import type { AgentProvider, ProcessManager } from '@shipcode/agents/source'; import { createClaudeCliProvider, createCodexCliProvider, + createGeminiCliProvider, createProviderRegistry, } from '@shipcode/agents/source'; import { DEFAULT_SETTINGS, type GitHubIssueCacheRecord } from '@shipcode/shared'; @@ -122,6 +123,7 @@ function createSmokeDeps() { const claudeProvider = createClaudeCliProvider(processManager); const codexProvider = createCodexCliProvider(processManager); + const geminiProvider = createGeminiCliProvider(processManager); const openrouterProvider: AgentProvider = { id: 'openrouter', supports: new Set(['plan', 'review', 'revision', 'verify']), @@ -131,6 +133,7 @@ function createSmokeDeps() { const providers = createProviderRegistry({ claude: claudeProvider, codex: codexProvider, + gemini: geminiProvider, openrouter: openrouterProvider, }); diff --git a/packages/shared/src/cli-model-capabilities.ts b/packages/shared/src/cli-model-capabilities.ts index 746f2600..2b45c746 100644 --- a/packages/shared/src/cli-model-capabilities.ts +++ b/packages/shared/src/cli-model-capabilities.ts @@ -1,6 +1,7 @@ import { CLAUDE_MODEL_OPTIONS, CODEX_FALLBACK_MODEL_OPTIONS, + GEMINI_FALLBACK_MODEL_OPTIONS, type KnownModelOption, } from './model-catalog'; import { getSupportedReasoningEfforts } from './reasoning-effort'; @@ -8,8 +9,8 @@ import type { CliModelCapabilities, CliModelCapabilityOption, ExecutorModel, - GeneratorCli, IntegrationStatus, + PhaseCliProvider, ReasoningEffort, } from './types'; @@ -19,7 +20,7 @@ export interface ModelAvailabilityAssessment { } function optionToCapability( - provider: GeneratorCli, + provider: PhaseCliProvider, option: KnownModelOption, ): CliModelCapabilityOption { return { @@ -32,10 +33,15 @@ function optionToCapability( } export function fallbackCliModelCapabilities( - provider: GeneratorCli, + provider: PhaseCliProvider, checkedAt = new Date(0).toISOString(), ): CliModelCapabilities { - const options = provider === 'claude' ? CLAUDE_MODEL_OPTIONS : CODEX_FALLBACK_MODEL_OPTIONS; + const options = + provider === 'claude' + ? CLAUDE_MODEL_OPTIONS + : provider === 'codex' + ? CODEX_FALLBACK_MODEL_OPTIONS + : GEMINI_FALLBACK_MODEL_OPTIONS; return { provider, source: 'fallback', @@ -43,21 +49,23 @@ export function fallbackCliModelCapabilities( error: provider === 'claude' ? null - : 'Codex model catalog could not be read; using conservative ShipCode presets.', + : provider === 'codex' + ? 'Codex model catalog could not be read; using conservative ShipCode presets.' + : 'Gemini model catalog could not be read; using conservative ShipCode presets.', checkedAt, }; } export function getProviderModelCapabilities( integrationStatus: IntegrationStatus | undefined, - provider: Extract, + provider: PhaseCliProvider, ): CliModelCapabilities { return getProviderModelCapabilitiesFromMap(integrationStatus?.modelCapabilities, provider); } export function getProviderModelCapabilitiesFromMap( - modelCapabilities: Partial> | null | undefined, - provider: Extract, + modelCapabilities: Partial> | null | undefined, + provider: PhaseCliProvider, ): CliModelCapabilities { return ( modelCapabilities?.[provider] ?? @@ -100,8 +108,8 @@ export function assessCliModelAvailability( } export function assessCliModelAvailabilityFromCapabilities( - modelCapabilities: Partial> | null | undefined, - provider: Extract, + modelCapabilities: Partial> | null | undefined, + provider: PhaseCliProvider, modelId: string | null | undefined, ): ModelAvailabilityAssessment { if (!modelId) return { available: true, message: null }; @@ -110,7 +118,8 @@ export function assessCliModelAvailabilityFromCapabilities( return { available: true, message: null }; } - const providerLabel = provider === 'claude' ? 'Claude CLI' : 'Codex CLI'; + const providerLabel = + provider === 'claude' ? 'Claude CLI' : provider === 'codex' ? 'Codex CLI' : 'Gemini CLI'; const sourceDetail = capabilities.source === 'catalog' ? 'installed CLI catalog' @@ -139,8 +148,8 @@ export function assessCliReasoningEffortAvailability( } export function assessCliReasoningEffortAvailabilityFromCapabilities( - modelCapabilities: Partial> | null | undefined, - provider: Extract, + modelCapabilities: Partial> | null | undefined, + provider: PhaseCliProvider, modelId: string | null | undefined, effort: ReasoningEffort, ): ModelAvailabilityAssessment { @@ -154,13 +163,13 @@ export function assessCliReasoningEffortAvailabilityFromCapabilities( const modelLabel = modelId ? ` for ${modelId}` : ''; return { available: false, - message: `${provider === 'claude' ? 'Claude CLI' : 'Codex CLI'} does not report ${effort} effort${modelLabel}. Choose a supported effort or update the CLI.`, + message: `${provider === 'claude' ? 'Claude CLI' : provider === 'codex' ? 'Codex CLI' : 'Gemini CLI'} does not report ${effort} effort${modelLabel}. Choose a supported effort or update the CLI.`, }; } export function assessCliSelectionAvailabilityFromCapabilities( - modelCapabilities: Partial> | null | undefined, - provider: Extract, + modelCapabilities: Partial> | null | undefined, + provider: PhaseCliProvider, modelId: string | null | undefined, effort: ReasoningEffort, ): ModelAvailabilityAssessment { diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index e6b52e2c..7b2f8bd6 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1,5 +1,5 @@ import { PINNED_MODEL_DEFAULTS } from './model-catalog'; -import { type AppSettings, PIPELINE_PHASE, type PipelinePhase } from './types'; +import { type AppSettings, type ExecutorModel, PIPELINE_PHASE, type PipelinePhase } from './types'; export const DEFAULT_NOTIFICATION_EVENTS = { awaitingApproval: true, @@ -17,6 +17,13 @@ export const DEFAULT_CHAT_NOTIFICATION_EVENTS = { ciBlocked: true, } as const; +export const PIPELINE_EXECUTOR_PROVIDERS = [ + 'claude', + 'codex', + 'gemini', + 'openrouter', +] as const satisfies readonly ExecutorModel[]; + export const DEFAULT_SETTINGS: AppSettings = { theme: 'system', fontStyle: 'dm-sans', diff --git a/packages/shared/src/model-catalog.test.ts b/packages/shared/src/model-catalog.test.ts index 5dfdbf5c..6d476bb1 100644 --- a/packages/shared/src/model-catalog.test.ts +++ b/packages/shared/src/model-catalog.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { CLAUDE_MODEL_OPTIONS, CODEX_FALLBACK_MODEL_OPTIONS, + GEMINI_FALLBACK_MODEL_OPTIONS, getKnownModelLabel, OPENROUTER_MODEL_IDS, PINNED_MODEL_DEFAULTS, @@ -20,6 +21,9 @@ describe('model-catalog', () => { prdRewrite: 'gpt-5.4-mini', triage: 'gpt-5.4-mini', }, + gemini: { + phase: 'gemini-2.5-pro', + }, openrouter: { paid: 'openrouter/auto', free: 'openrouter/free', @@ -37,6 +41,15 @@ describe('model-catalog', () => { ]); }); + it('exposes Gemini fallback options and labels', () => { + expect(GEMINI_FALLBACK_MODEL_OPTIONS.map((option) => option.value)).toEqual([ + 'gemini-2.5-pro', + 'gemini-2.5-flash', + ]); + expect(getKnownModelLabel('gemini')).toBe('Gemini 2.5 Pro'); + expect(getKnownModelLabel('gemini-2.5-flash')).toBe('Gemini 2.5 Flash'); + }); + it('normalizes friendly labels for provider aliases and upstream slugs', () => { expect(getKnownModelLabel('claude')).toBe('Sonnet 4.6'); expect(getKnownModelLabel('codex')).toBe('GPT-5.5'); diff --git a/packages/shared/src/model-catalog.ts b/packages/shared/src/model-catalog.ts index ab3a18d3..8bc34880 100644 --- a/packages/shared/src/model-catalog.ts +++ b/packages/shared/src/model-catalog.ts @@ -21,6 +21,14 @@ export const CODEX_FALLBACK_MODEL_IDS = { export type CodexFallbackModelId = (typeof CODEX_FALLBACK_MODEL_IDS)[keyof typeof CODEX_FALLBACK_MODEL_IDS]; +export const GEMINI_FALLBACK_MODEL_IDS = { + pro: 'gemini-2.5-pro', + flash: 'gemini-2.5-flash', +} as const; + +export type GeminiFallbackModelId = + (typeof GEMINI_FALLBACK_MODEL_IDS)[keyof typeof GEMINI_FALLBACK_MODEL_IDS]; + export const OPENROUTER_MODEL_IDS = { autoPaid: 'openrouter/auto', autoFree: 'openrouter/free', @@ -47,6 +55,11 @@ export const CODEX_FALLBACK_MODEL_OPTIONS = [ { value: CODEX_FALLBACK_MODEL_IDS.gpt54Mini, label: 'GPT-5.4 Mini' }, ] as const satisfies readonly KnownModelOption[]; +export const GEMINI_FALLBACK_MODEL_OPTIONS = [ + { value: GEMINI_FALLBACK_MODEL_IDS.pro, label: 'Gemini 2.5 Pro' }, + { value: GEMINI_FALLBACK_MODEL_IDS.flash, label: 'Gemini 2.5 Flash' }, +] as const satisfies readonly KnownModelOption[]; + export const OPENROUTER_MODEL_OPTIONS = [ { value: OPENROUTER_MODEL_IDS.autoPaid, label: 'Auto (paid)' }, { value: OPENROUTER_MODEL_IDS.autoFree, label: 'Auto (free)' }, @@ -58,12 +71,16 @@ export const OPENROUTER_MODEL_OPTIONS = [ export type CuratedModelId = | (typeof CLAUDE_MODEL_OPTIONS)[number]['value'] | (typeof CODEX_FALLBACK_MODEL_OPTIONS)[number]['value'] + | (typeof GEMINI_FALLBACK_MODEL_OPTIONS)[number]['value'] | (typeof OPENROUTER_MODEL_OPTIONS)[number]['value']; const CURATED_MODEL_LABELS = Object.fromEntries( - [...CLAUDE_MODEL_OPTIONS, ...CODEX_FALLBACK_MODEL_OPTIONS, ...OPENROUTER_MODEL_OPTIONS].map( - (option) => [option.value, option.label], - ), + [ + ...CLAUDE_MODEL_OPTIONS, + ...CODEX_FALLBACK_MODEL_OPTIONS, + ...GEMINI_FALLBACK_MODEL_OPTIONS, + ...OPENROUTER_MODEL_OPTIONS, + ].map((option) => [option.value, option.label]), ) as Record; export const PINNED_MODEL_DEFAULTS = { @@ -77,6 +94,9 @@ export const PINNED_MODEL_DEFAULTS = { prdRewrite: CODEX_FALLBACK_MODEL_IDS.gpt54Mini, triage: CODEX_FALLBACK_MODEL_IDS.gpt54Mini, }, + gemini: { + phase: GEMINI_FALLBACK_MODEL_IDS.pro, + }, openrouter: { paid: OPENROUTER_MODEL_IDS.autoPaid, free: OPENROUTER_MODEL_IDS.autoFree, @@ -87,6 +107,7 @@ export const PINNED_MODEL_DEFAULTS = { export const KNOWN_MODEL_LABELS: Record = { claude: CURATED_MODEL_LABELS[PINNED_MODEL_DEFAULTS.claude.phase], codex: CURATED_MODEL_LABELS[PINNED_MODEL_DEFAULTS.codex.phase], + gemini: CURATED_MODEL_LABELS[PINNED_MODEL_DEFAULTS.gemini.phase], openrouter: 'OpenRouter', ...CURATED_MODEL_LABELS, 'anthropic/claude-sonnet-4-6': CURATED_MODEL_LABELS[OPENROUTER_MODEL_IDS.claudeSonnet46], diff --git a/packages/shared/src/model-display.ts b/packages/shared/src/model-display.ts index 5e9f3b2b..87f3860e 100644 --- a/packages/shared/src/model-display.ts +++ b/packages/shared/src/model-display.ts @@ -5,12 +5,14 @@ import type { ExecutorModel } from './types'; export const PROVIDER_DISPLAY: Record = { claude: 'Claude', codex: 'Codex', + gemini: 'Gemini', openrouter: 'OpenRouter', }; export const MODEL_DISPLAY: Record = { ...KNOWN_MODEL_LABELS }; const CODEX_MODEL_PATTERN = /^gpt-5(?:[.-]|$)/i; +const GEMINI_MODEL_PATTERN = /^gemini(?:[.-]|$)/i; function normalizeModel(value: string | null | undefined): string | null { const sanitized = sanitizeResolvedModel(value); @@ -42,6 +44,10 @@ export function inferProviderFromModel( return 'codex'; } + if (normalized === 'gemini' || GEMINI_MODEL_PATTERN.test(normalized)) { + return 'gemini'; + } + if ( normalized === 'openrouter' || normalized.startsWith('openrouter/') || diff --git a/packages/shared/src/model-resolution.test.ts b/packages/shared/src/model-resolution.test.ts index c07fdecc..ff68af44 100644 --- a/packages/shared/src/model-resolution.test.ts +++ b/packages/shared/src/model-resolution.test.ts @@ -180,13 +180,13 @@ describe('model-resolution', () => { it('lets project overrides shadow the global defaults', () => { const project = makeProject({ - plannerModelOverride: 'openrouter', + plannerModelOverride: 'gemini', reviewerModelOverride: 'claude', executorModelOverride: 'codex', verifierModelOverride: 'claude', }); - expect(resolvePhaseModel(settings, project, 'planner')).toBe('openrouter'); + expect(resolvePhaseModel(settings, project, 'planner')).toBe('gemini'); expect(resolvePhaseModel(settings, project, 'reviewer')).toBe('claude'); expect(resolvePhaseModel(settings, project, 'executor')).toBe('codex'); expect(resolvePhaseModel(settings, project, 'verifier')).toBe('claude'); @@ -197,13 +197,13 @@ describe('model-resolution', () => { const issue = makeIssue({ plannerModelOverride: 'openrouter', reviewerModelOverride: 'claude', - executorModelOverride: 'openrouter', + executorModelOverride: 'gemini', verifierModelOverride: 'claude', }); expect(resolvePhaseModelForIssue(settings, project, issue, 'planner')).toBe('openrouter'); expect(resolvePhaseModelForIssue(settings, project, issue, 'reviewer')).toBe('claude'); - expect(resolveExecutorModelForIssue(settings, project, issue)).toBe('openrouter'); + expect(resolveExecutorModelForIssue(settings, project, issue)).toBe('gemini'); expect(resolvePhaseModelForIssue(settings, project, issue, 'verifier')).toBe('claude'); }); diff --git a/packages/shared/src/model-resolution.ts b/packages/shared/src/model-resolution.ts index 352cc686..2e9a95b1 100644 --- a/packages/shared/src/model-resolution.ts +++ b/packages/shared/src/model-resolution.ts @@ -148,6 +148,7 @@ export interface RequireApprovalResolution { const VALID_PHASE_PROVIDERS = [ 'claude', 'codex', + 'gemini', 'openrouter', ] as const satisfies readonly ExecutorModel[]; @@ -226,7 +227,9 @@ export function getPhaseDescriptor(phase: ResolvedPhaseModel): ResolvedPhaseDesc } function asExecutorModel(value: string | null | undefined): ExecutorModel | null { - if (value === 'claude' || value === 'codex' || value === 'openrouter') return value; + if (value === 'claude' || value === 'codex' || value === 'gemini' || value === 'openrouter') { + return value; + } return null; } diff --git a/packages/shared/src/reasoning-effort.ts b/packages/shared/src/reasoning-effort.ts index 31537990..d72787fa 100644 --- a/packages/shared/src/reasoning-effort.ts +++ b/packages/shared/src/reasoning-effort.ts @@ -16,6 +16,12 @@ const CODEX_REASONING_EFFORTS = [ 'xhigh', ] as const satisfies readonly ReasoningEffort[]; +const GEMINI_REASONING_EFFORTS = [ + 'low', + 'medium', + 'high', +] as const satisfies readonly ReasoningEffort[]; + const CLAUDE_REASONING_EFFORTS = [ 'none', 'medium', @@ -87,6 +93,10 @@ export function getSupportedReasoningEfforts( return CODEX_REASONING_EFFORTS; } + if (provider === 'gemini') { + return GEMINI_REASONING_EFFORTS; + } + if (normalizedModelId && OPENROUTER_ADAPTIVE_CLAUDE_MODELS.has(normalizedModelId)) { return OPENROUTER_ADAPTIVE_CLAUDE_EFFORTS; } @@ -145,6 +155,26 @@ export function resolveProviderReasoningEffort( return { configured, effective: configured, exact: true, message: null }; } + if (provider === 'gemini') { + if (configured === 'none' || configured === 'minimal') { + return { + configured, + effective: 'low', + exact: false, + message: `${normalizedModelId ?? 'Gemini'} supports Low, Medium, and High reasoning effort. Using ${formatReasoningEffortLabel('low')}.`, + }; + } + if (configured === 'xhigh') { + return { + configured, + effective: 'high', + exact: false, + message: `${normalizedModelId ?? 'Gemini'} supports Low, Medium, and High reasoning effort. Using ${formatReasoningEffortLabel('high')}.`, + }; + } + return { configured, effective: configured, exact: true, message: null }; + } + if (normalizedModelId && OPENROUTER_NO_REASONING_MODELS.has(normalizedModelId)) { if (configured === 'none') { return { configured, effective: 'none', exact: true, message: null }; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 2dc13850..0f9b027a 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -564,7 +564,7 @@ export interface PipelineCheckpoint { // === Pipeline Types === -export type AgentType = 'claude' | 'codex' | 'gh' | 'openrouter' | 'shell'; +export type AgentType = 'claude' | 'codex' | 'gemini' | 'gh' | 'openrouter' | 'shell'; /** * The subset of AgentType that can drive a pipeline phase. Excludes @@ -581,9 +581,11 @@ export type AgentType = 'claude' | 'codex' | 'gh' | 'openrouter' | 'shell'; * - SQLite stores strings; no enum ⇄ ordinal round-trip needed. * - GitHub label values are already strings (`shipcode:agent:claude`, etc). */ -export type ExecutorModel = 'claude' | 'codex' | 'openrouter'; +export type ExecutorModel = 'claude' | 'codex' | 'gemini' | 'openrouter'; +export type TriageModel = Exclude; export type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'; export type GeneratorCli = 'claude' | 'codex'; +export type PhaseCliProvider = Extract; export type ContextGeneratorCli = GeneratorCli; export type RevisionCount = 0 | 1 | 2 | 3 | 4 | 5; export type PipelineSpeedProfile = 'smart_fast' | 'thorough'; @@ -747,7 +749,7 @@ export interface AppSettings { reviewerModel: AgentType; verifierModel: AgentType; executorModel: AgentType; - triageModel: ExecutorModel; + triageModel: TriageModel; triageModelId: string | null; triageReasoningEffort: ReasoningEffort; triageAutoApplyThreshold: number; @@ -1089,7 +1091,7 @@ export interface CliModelCapabilityOption { } export interface CliModelCapabilities { - provider: GeneratorCli; + provider: PhaseCliProvider; source: CliModelCapabilitySource; models: CliModelCapabilityOption[]; error: string | null; @@ -1162,6 +1164,7 @@ export interface DesktopAppHealthMap { export interface SystemHealth { claude: CliHealth; codex: CliHealth; + gemini?: CliHealth; git: CliHealth; gh: CliHealth; } @@ -1213,7 +1216,7 @@ export interface ChatIntegrationHealth { export interface IntegrationStatus { system: SystemHealth; - modelCapabilities?: Record; + modelCapabilities?: Record; ghAuth: GhAuthStatus; openrouter: OpenRouterHealth; discord: ChatIntegrationHealth; diff --git a/packages/ui/src/kanban-board/types.ts b/packages/ui/src/kanban-board/types.ts index c7b7c76a..31957989 100644 --- a/packages/ui/src/kanban-board/types.ts +++ b/packages/ui/src/kanban-board/types.ts @@ -77,7 +77,7 @@ export type BoardColumn = { export type IssuePhaseChip = { phase: ResolvedPhaseModel; - provider: 'claude' | 'codex' | 'openrouter'; + provider: 'claude' | 'codex' | 'gemini' | 'openrouter'; model: string; effort: string | null; }; From d08f8a27c9514083b4ca334439fe212b25c98037 Mon Sep 17 00:00:00 2001 From: VincentShipsIt Date: Fri, 8 May 2026 19:48:46 +0200 Subject: [PATCH 2/7] Stabilize pipeline planning tests --- packages/pipeline/src/pipeline-step-log.test.ts | 10 ++++++++++ packages/pipeline/src/prompt-telemetry.test.ts | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/packages/pipeline/src/pipeline-step-log.test.ts b/packages/pipeline/src/pipeline-step-log.test.ts index e36e3f21..857e7c41 100644 --- a/packages/pipeline/src/pipeline-step-log.test.ts +++ b/packages/pipeline/src/pipeline-step-log.test.ts @@ -4,6 +4,16 @@ import { describe, expect, it, vi } from 'vitest'; import { createPipeline } from './pipeline'; import type { PipelineDeps, PipelineEvent } from './types'; +vi.mock('@shipcode/agents/source', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadCodeReviewGraphContext: vi.fn(() => []), + loadRepoContext: vi.fn(() => 'No repo memory files found.'), + loadStructuredRepoContext: vi.fn(() => []), + }; +}); + const plan: ShipCodePlan = { id: 'p1', threadId: 't1', diff --git a/packages/pipeline/src/prompt-telemetry.test.ts b/packages/pipeline/src/prompt-telemetry.test.ts index 8999615e..99a0abf9 100644 --- a/packages/pipeline/src/prompt-telemetry.test.ts +++ b/packages/pipeline/src/prompt-telemetry.test.ts @@ -4,6 +4,16 @@ import { describe, expect, it, vi } from 'vitest'; import { createPipeline } from './pipeline'; import type { PipelineDeps, PipelineEvent } from './types'; +vi.mock('@shipcode/agents/source', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadCodeReviewGraphContext: vi.fn(() => []), + loadRepoContext: vi.fn(() => 'No repo memory files found.'), + loadStructuredRepoContext: vi.fn(() => []), + }; +}); + const plan: ShipCodePlan = { id: 'p1', threadId: 't1', From 34d4a82cf83044a960669c45e52dd21a4f1c114d Mon Sep 17 00:00:00 2001 From: VincentShipsIt Date: Fri, 8 May 2026 19:58:41 +0200 Subject: [PATCH 3/7] Harden Gemini CLI provider --- apps/desktop/src/main/ipc/helpers.ts | 5 ++- .../issue-detail/PipelineTab.test.tsx | 42 +++++++++++++++++-- .../components/issue-detail/PipelineTab.tsx | 5 ++- .../src/providers/gemini-cli-provider.test.ts | 14 ++++++- .../src/providers/gemini-cli-provider.ts | 25 +++++------ .../pipeline/src/pipeline-step-log.test.ts | 10 ----- .../pipeline/src/prompt-telemetry.test.ts | 10 ----- 7 files changed, 71 insertions(+), 40 deletions(-) diff --git a/apps/desktop/src/main/ipc/helpers.ts b/apps/desktop/src/main/ipc/helpers.ts index 97b5f9ea..d636f4c8 100644 --- a/apps/desktop/src/main/ipc/helpers.ts +++ b/apps/desktop/src/main/ipc/helpers.ts @@ -154,10 +154,11 @@ export async function assertCliPhaseModelsSupported(phaseModels: PhaseModels): P }>; for (const phase of phases) { - if (phase.provider === 'openrouter') continue; + const cliProvider = phase.provider; + if (cliProvider === 'openrouter') continue; const selection = assessCliSelectionAvailabilityFromCapabilities( capabilities, - phase.provider, + cliProvider, phase.modelId, phase.effort, ); diff --git a/apps/desktop/src/renderer/components/issue-detail/PipelineTab.test.tsx b/apps/desktop/src/renderer/components/issue-detail/PipelineTab.test.tsx index 421b7cae..64e90eb0 100644 --- a/apps/desktop/src/renderer/components/issue-detail/PipelineTab.test.tsx +++ b/apps/desktop/src/renderer/components/issue-detail/PipelineTab.test.tsx @@ -1,7 +1,12 @@ // @vitest-environment jsdom import '@testing-library/jest-dom/vitest'; -import type { FeatureQaResult, GitHubIssueCacheRecord, Thread } from '@shipcode/shared'; +import type { + FeatureQaResult, + GitHubIssueCacheRecord, + IntegrationStatus, + Thread, +} from '@shipcode/shared'; import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { PipelineTab } from './PipelineTab'; @@ -101,9 +106,13 @@ function makeThread(overrides: Partial = {}): Thread { function renderPipelineTab({ issueOverrides = {}, + executorEditable = false, + integrationStatus, qaResults = [], }: { issueOverrides?: Partial; + executorEditable?: boolean; + integrationStatus?: IntegrationStatus; qaResults?: FeatureQaResult[]; } = {}) { render( @@ -118,7 +127,10 @@ function renderPipelineTab({ verifier: 'high', }} currentPhaseSelections={{ - planner: { provider: 'claude', modelId: null }, + planner: { + provider: executorEditable ? 'gemini' : 'claude', + modelId: executorEditable ? 'missing-gemini-model' : null, + }, reviewer: { provider: 'codex', modelId: null }, executor: { provider: 'claude', modelId: null }, verifier: { provider: 'claude', modelId: null }, @@ -131,7 +143,7 @@ function renderPipelineTab({ }} effectiveRequireApproval={false} effectiveRevisionCount={1} - executorEditable={false} + executorEditable={executorEditable} hasPrFeedbackBlockers={false} inheritedPhaseReasoningEfforts={{ planner: 'high', @@ -141,6 +153,7 @@ function renderPipelineTab({ }} inheritedRequireApproval={true} inheritedRevisionCount={0} + integrationStatus={integrationStatus} isSubmitting={false} linkedPrUrl={null} phaseEffortSelectValues={{ @@ -152,7 +165,7 @@ function renderPipelineTab({ phaseModelValidation={{}} qaResults={qaResults} phaseSelectValues={{ - planner: '__inherit__', + planner: executorEditable ? 'gemini::missing-gemini-model' : '__inherit__', reviewer: '__inherit__', executor: '__inherit__', verifier: '__inherit__', @@ -207,6 +220,27 @@ describe('PipelineTab', () => { expect(screen.getByText('1 revision before approval/execution.')).toBeInTheDocument(); }); + it('renders Gemini in editable thread-level phase selectors with degraded readiness', () => { + renderPipelineTab({ + executorEditable: true, + integrationStatus: { + modelCapabilities: { + gemini: { + provider: 'gemini', + source: 'unavailable', + models: [], + error: 'Gemini CLI is not authenticated.', + checkedAt: '2026-05-08T00:00:00.000Z', + }, + }, + } as unknown as IntegrationStatus, + }); + + expect(screen.getByText('missing-gemini-model (Unavailable)')).toBeInTheDocument(); + expect(screen.getByText(/missing-gemini-model is not reported/)).toBeInTheDocument(); + expect(screen.getByText('Human Approval')).toBeInTheDocument(); + }); + it('renders visual QA assertion evidence', () => { renderPipelineTab({ qaResults: [ diff --git a/apps/desktop/src/renderer/components/issue-detail/PipelineTab.tsx b/apps/desktop/src/renderer/components/issue-detail/PipelineTab.tsx index c322d952..653d52c5 100644 --- a/apps/desktop/src/renderer/components/issue-detail/PipelineTab.tsx +++ b/apps/desktop/src/renderer/components/issue-detail/PipelineTab.tsx @@ -260,7 +260,10 @@ export function PipelineTab({ value={phaseSelectValues[phase]} onValueChange={(value: string) => onPhaseAgentChange(phase, value)} > - + {phaseSelectValues[phase] === '__inherit__' ? ( { const provider = createGeminiCliProvider(processManager as unknown as ProcessManager); const resultPromise = provider.generate(req()); - processManager.emit('output', 'proc-1', 'Auth failed\nPROMPT\n'.repeat(50)); + processManager.emit('output', 'proc-1', 'PROMPT\nAuth failed\n'.repeat(50)); processManager.emit('exit', 'proc-1', 1); const result = await resultPromise; @@ -88,6 +88,18 @@ describe('gemini-cli-provider', () => { expect(result.providerError?.message).not.toContain('PROMPT'); }); + it('does not fall back to passing the prompt through argv', async () => { + const processManager = new FakeProcessManager(); + processManager.spawnWithStdin = undefined as unknown as FakeProcessManager['spawnWithStdin']; + const provider = createGeminiCliProvider(processManager as unknown as ProcessManager); + + const result = await provider.generate(req()); + + expect(processManager.spawn).not.toHaveBeenCalled(); + expect(result.exitCode).toBe(1); + expect(result.providerError?.message).not.toContain('PROMPT'); + }); + it('emits canonical lifecycle, raw, and done terminal events', async () => { const processManager = new FakeProcessManager(); const onTerminalEvent = vi.fn(); diff --git a/packages/agents/src/providers/gemini-cli-provider.ts b/packages/agents/src/providers/gemini-cli-provider.ts index 52412c76..08edf869 100644 --- a/packages/agents/src/providers/gemini-cli-provider.ts +++ b/packages/agents/src/providers/gemini-cli-provider.ts @@ -61,14 +61,10 @@ async function runGeminiCli( options, ); } else { - process = processManager.spawn( - 'gemini', - 'gemini', - ['-p', command.stdin, ...command.args.slice(2)], - req.cwd, - req.threadId, - options, - ); + return { + rawOutput: 'Gemini CLI stdin execution is unavailable', + exitCode: 1, + }; } } catch (err) { return { rawOutput: err instanceof Error ? err.message : String(err), exitCode: 127 }; @@ -158,11 +154,16 @@ function parseGeminiOutput(rawOutput: string): { text: string; resolvedModel?: s } } -function clampGeminiFailure(rawOutput: string): string { - const line = stripAnsi(rawOutput) +function clampGeminiFailure(rawOutput: string, prompt: string): string { + const promptText = stripAnsi(prompt).trim(); + const lines = stripAnsi(rawOutput) .split('\n') .map((entry) => entry.trim()) - .find(Boolean); + .filter(Boolean) + .filter((entry) => !promptText || (!promptText.includes(entry) && !entry.includes(promptText))); + const line = + lines.find((entry) => /\b(error|failed|unauthorized|auth|permission|denied)\b/i.test(entry)) ?? + lines[0]; return (line ?? 'Gemini CLI failed').slice(0, 280); } @@ -199,7 +200,7 @@ export function createGeminiCliProvider(processManager: ProcessManager): AgentPr ? { providerError: { kind: 'unknown' as const, - message: clampGeminiFailure(result.rawOutput), + message: clampGeminiFailure(result.rawOutput, req.prompt), retryable: true, }, } diff --git a/packages/pipeline/src/pipeline-step-log.test.ts b/packages/pipeline/src/pipeline-step-log.test.ts index 857e7c41..e36e3f21 100644 --- a/packages/pipeline/src/pipeline-step-log.test.ts +++ b/packages/pipeline/src/pipeline-step-log.test.ts @@ -4,16 +4,6 @@ import { describe, expect, it, vi } from 'vitest'; import { createPipeline } from './pipeline'; import type { PipelineDeps, PipelineEvent } from './types'; -vi.mock('@shipcode/agents/source', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadCodeReviewGraphContext: vi.fn(() => []), - loadRepoContext: vi.fn(() => 'No repo memory files found.'), - loadStructuredRepoContext: vi.fn(() => []), - }; -}); - const plan: ShipCodePlan = { id: 'p1', threadId: 't1', diff --git a/packages/pipeline/src/prompt-telemetry.test.ts b/packages/pipeline/src/prompt-telemetry.test.ts index 99a0abf9..8999615e 100644 --- a/packages/pipeline/src/prompt-telemetry.test.ts +++ b/packages/pipeline/src/prompt-telemetry.test.ts @@ -4,16 +4,6 @@ import { describe, expect, it, vi } from 'vitest'; import { createPipeline } from './pipeline'; import type { PipelineDeps, PipelineEvent } from './types'; -vi.mock('@shipcode/agents/source', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadCodeReviewGraphContext: vi.fn(() => []), - loadRepoContext: vi.fn(() => 'No repo memory files found.'), - loadStructuredRepoContext: vi.fn(() => []), - }; -}); - const plan: ShipCodePlan = { id: 'p1', threadId: 't1', From d1884b8fc32a033bc40bb91893baeffc3b59101d Mon Sep 17 00:00:00 2001 From: VincentShipsIt Date: Fri, 8 May 2026 20:05:08 +0200 Subject: [PATCH 4/7] Fix pipeline prompt telemetry test timeouts --- packages/pipeline/src/pipeline-prompt-telemetry.test.ts | 4 ++++ packages/pipeline/src/prompt-telemetry.test.ts | 2 ++ 2 files changed, 6 insertions(+) diff --git a/packages/pipeline/src/pipeline-prompt-telemetry.test.ts b/packages/pipeline/src/pipeline-prompt-telemetry.test.ts index 0ea2df26..9aa6e4b2 100644 --- a/packages/pipeline/src/pipeline-prompt-telemetry.test.ts +++ b/packages/pipeline/src/pipeline-prompt-telemetry.test.ts @@ -152,6 +152,7 @@ describe('pipeline prompt telemetry', () => { const promptTelemetryCreate = vi.fn(); const { deps } = makeDeps({ promptTelemetryCreate }); const pipeline = createPipeline(deps); + pipeline.initializeContext('t1', { projectPath: '/tmp/project', repoPromptMaterials: [] }); await pipeline.startPlanGeneration('t1', 'Do the thing', '/tmp/project', null); await flush(); @@ -188,6 +189,7 @@ describe('pipeline prompt telemetry', () => { }, }); const pipeline = createPipeline(deps); + pipeline.initializeContext('t1', { projectPath: '/tmp/project', repoPromptMaterials: [] }); await pipeline.startFromGitHubIssue( 't1', @@ -208,6 +210,7 @@ describe('pipeline prompt telemetry', () => { }); const { deps } = makeDeps({ promptTelemetryCreate }); const pipeline = createPipeline(deps); + pipeline.initializeContext('t1', { projectPath: '/tmp/project', repoPromptMaterials: [] }); await pipeline.startPlanGeneration('t1', 'Do the thing', '/tmp/project', null); await flush(); @@ -229,6 +232,7 @@ describe('pipeline prompt telemetry', () => { }, }); const pipeline = createPipeline(deps); + pipeline.initializeContext('t1', { projectPath: '/tmp/project', repoPromptMaterials: [] }); await pipeline.startFromGitHubIssue( 't1', diff --git a/packages/pipeline/src/prompt-telemetry.test.ts b/packages/pipeline/src/prompt-telemetry.test.ts index 8999615e..3b526b70 100644 --- a/packages/pipeline/src/prompt-telemetry.test.ts +++ b/packages/pipeline/src/prompt-telemetry.test.ts @@ -109,6 +109,7 @@ describe('pipeline prompt telemetry', () => { const create = vi.fn(); const { deps } = makeDeps(create); const pipeline = createPipeline(deps); + pipeline.initializeContext('t1', { projectPath: '/tmp/project', repoPromptMaterials: [] }); await pipeline.startPlanGeneration('t1', 'do stuff', '/tmp/project', null); await flush(); @@ -143,6 +144,7 @@ describe('pipeline prompt telemetry', () => { }); const { deps } = makeDeps(create); const pipeline = createPipeline(deps); + pipeline.initializeContext('t1', { projectPath: '/tmp/project', repoPromptMaterials: [] }); await pipeline.startPlanGeneration('t1', 'do stuff', '/tmp/project', null); await flush(); From 90b6cbb6880995b0a06a05bcfa6a6e89df26d60d Mon Sep 17 00:00:00 2001 From: VincentShipsIt Date: Fri, 8 May 2026 20:12:35 +0200 Subject: [PATCH 5/7] Harden ProcessManager stdin command surface --- packages/agents/src/process-manager.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/agents/src/process-manager.ts b/packages/agents/src/process-manager.ts index 523ecb3e..e6b1aba7 100644 --- a/packages/agents/src/process-manager.ts +++ b/packages/agents/src/process-manager.ts @@ -6,7 +6,14 @@ import { assertWorkspaceSafe } from '@shipcode/shared/worktree-path'; import { nanoid } from 'nanoid'; import * as pty from 'node-pty'; -const ALLOWED_COMMANDS = new Set(['claude', 'codex', 'gemini', 'gh']); +type AllowlistedAgentCommand = Extract; + +const ALLOWED_AGENT_COMMANDS = new Set([ + 'claude', + 'codex', + 'gemini', + 'gh', +]); const TRUSTED_SHELLS = new Set([ '/bin/bash', '/bin/zsh', @@ -65,7 +72,10 @@ function getShellEnv(): Record { } function resolveCommand(command: string): string { - if (!ALLOWED_COMMANDS.has(command)) return command; + if (TRUSTED_SHELLS.has(command)) return command; + if (!ALLOWED_AGENT_COMMANDS.has(command as AllowlistedAgentCommand)) { + throw new Error(`Command is not allowlisted for ProcessManager: ${command}`); + } const shell = process.env.SHELL; if (!shell || !TRUSTED_SHELLS.has(shell)) return command; try { @@ -227,6 +237,11 @@ export class ProcessManager extends EventEmitter { return managed; } + /** + * Spawn an allowlisted CLI with stdin piped from `input`. This is the + * non-PTY subprocess surface for one-shot agent runs that must pass large + * prompts via stdin instead of argv. + */ spawnWithStdin( type: AgentType, command: string, From 4d79c6e84b9d2de7027b3bf653f8e1b1efabdae6 Mon Sep 17 00:00:00 2001 From: VincentShipsIt Date: Fri, 8 May 2026 20:16:29 +0200 Subject: [PATCH 6/7] [shipcode] node checkpoint --- packages/agents/src/process-manager.ts | 44 ++++++++++++++++++++------ 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/packages/agents/src/process-manager.ts b/packages/agents/src/process-manager.ts index e6b1aba7..ab6e9c6a 100644 --- a/packages/agents/src/process-manager.ts +++ b/packages/agents/src/process-manager.ts @@ -7,6 +7,8 @@ import { nanoid } from 'nanoid'; import * as pty from 'node-pty'; type AllowlistedAgentCommand = Extract; +type TrustedShellCommand = (typeof TRUSTED_SHELL_COMMANDS)[number]; +type ProcessManagerCommand = AllowlistedAgentCommand | TrustedShellCommand; const ALLOWED_AGENT_COMMANDS = new Set([ 'claude', @@ -14,7 +16,7 @@ const ALLOWED_AGENT_COMMANDS = new Set([ 'gemini', 'gh', ]); -const TRUSTED_SHELLS = new Set([ +const TRUSTED_SHELL_COMMANDS = [ '/bin/bash', '/bin/zsh', '/bin/sh', @@ -24,7 +26,8 @@ const TRUSTED_SHELLS = new Set([ '/usr/local/bin/zsh', '/opt/homebrew/bin/bash', '/opt/homebrew/bin/zsh', -]); +] as const; +const TRUSTED_SHELLS = new Set(TRUSTED_SHELL_COMMANDS); const SAFE_ENV_KEYS = new Set([ 'PATH', @@ -53,6 +56,31 @@ function filterEnv(env: Record): Record { return filtered; } +function isTrustedShell(command: string): command is TrustedShellCommand { + return TRUSTED_SHELLS.has(command); +} + +function isAllowlistedAgentCommand(command: string): command is AllowlistedAgentCommand { + return ALLOWED_AGENT_COMMANDS.has(command as AllowlistedAgentCommand); +} + +function assertProcessManagerCommand(command: string): asserts command is ProcessManagerCommand { + if (isTrustedShell(command) || isAllowlistedAgentCommand(command)) return; + throw new Error(`Command is not allowlisted for ProcessManager: ${command}`); +} + +function mergeSafeEnv( + baseEnv: Record, + extraEnv?: Record, +): Record { + const env: Record = { ...filterEnv(baseEnv), FORCE_COLOR: '1' }; + for (const [key, val] of Object.entries(extraEnv ?? {})) { + if (!key || key.includes('=') || key.includes('\0') || val.includes('\0')) continue; + env[key] = val; + } + return env; +} + function getShellEnv(): Record { try { const shell = process.env.SHELL ?? '/bin/zsh'; @@ -72,12 +100,10 @@ function getShellEnv(): Record { } function resolveCommand(command: string): string { - if (TRUSTED_SHELLS.has(command)) return command; - if (!ALLOWED_AGENT_COMMANDS.has(command as AllowlistedAgentCommand)) { - throw new Error(`Command is not allowlisted for ProcessManager: ${command}`); - } + assertProcessManagerCommand(command); + if (isTrustedShell(command)) return command; const shell = process.env.SHELL; - if (!shell || !TRUSTED_SHELLS.has(shell)) return command; + if (!shell || !isTrustedShell(shell)) return command; try { const resolved = execFileSync(shell, ['-ilc', `command -v ${command}`], { encoding: 'utf-8', @@ -169,7 +195,7 @@ export class ProcessManager extends EventEmitter { cols: 120, rows: 30, cwd, - env: { ...filterEnv(cachedEnv), FORCE_COLOR: '1' }, + env: mergeSafeEnv(cachedEnv, options.extraEnv), }); } catch (err) { // Spawn failed (e.g. binary not found, alias instead of real path). @@ -266,7 +292,7 @@ export class ProcessManager extends EventEmitter { let child: ChildProcessWithoutNullStreams; const detached = options.detached ?? false; - const env = { ...filterEnv(cachedEnv), FORCE_COLOR: '1', ...options.extraEnv }; + const env = mergeSafeEnv(cachedEnv, options.extraEnv); try { child = spawnChild(resolvedCommand, args, { From 6734d872c69a550711b950d1615495ebaa62e620 Mon Sep 17 00:00:00 2001 From: VincentShipsIt Date: Fri, 8 May 2026 20:20:37 +0200 Subject: [PATCH 7/7] Clarify ProcessManager stdin subprocess contract --- packages/agents/src/process-manager.ts | 40 +++++++++++++++----------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/packages/agents/src/process-manager.ts b/packages/agents/src/process-manager.ts index ab6e9c6a..6acd9fa2 100644 --- a/packages/agents/src/process-manager.ts +++ b/packages/agents/src/process-manager.ts @@ -6,11 +6,11 @@ import { assertWorkspaceSafe } from '@shipcode/shared/worktree-path'; import { nanoid } from 'nanoid'; import * as pty from 'node-pty'; -type AllowlistedAgentCommand = Extract; -type TrustedShellCommand = (typeof TRUSTED_SHELL_COMMANDS)[number]; -type ProcessManagerCommand = AllowlistedAgentCommand | TrustedShellCommand; +export type ProcessManagerAgentCommand = Extract; +export type ProcessManagerShellCommand = (typeof TRUSTED_SHELL_COMMANDS)[number]; +export type ProcessManagerCommand = ProcessManagerAgentCommand | ProcessManagerShellCommand; -const ALLOWED_AGENT_COMMANDS = new Set([ +const ALLOWED_AGENT_COMMANDS = new Set([ 'claude', 'codex', 'gemini', @@ -56,12 +56,12 @@ function filterEnv(env: Record): Record { return filtered; } -function isTrustedShell(command: string): command is TrustedShellCommand { +function isTrustedShell(command: string): command is ProcessManagerShellCommand { return TRUSTED_SHELLS.has(command); } -function isAllowlistedAgentCommand(command: string): command is AllowlistedAgentCommand { - return ALLOWED_AGENT_COMMANDS.has(command as AllowlistedAgentCommand); +function isAllowlistedAgentCommand(command: string): command is ProcessManagerAgentCommand { + return ALLOWED_AGENT_COMMANDS.has(command as ProcessManagerAgentCommand); } function assertProcessManagerCommand(command: string): asserts command is ProcessManagerCommand { @@ -81,6 +81,11 @@ function mergeSafeEnv( return env; } +function assertWorkspacePolicy(cwd: string, options: ManagedProcessSpawnOptions): void { + if (options.workspaceRoot === undefined) return; + assertWorkspaceSafe({ workspacePath: cwd, workspaceRoot: options.workspaceRoot }); +} + function getShellEnv(): Record { try { const shell = process.env.SHELL ?? '/bin/zsh'; @@ -175,12 +180,10 @@ export class ProcessManager extends EventEmitter { const outputMode = options.outputMode ?? 'normalized'; // Defense in depth: when the caller declares a workspaceRoot policy, - // assert the cwd before pty.spawn. A mismatch here means the pipeline + // assert the cwd before spawning. A mismatch here means the pipeline // is about to run an agent in the wrong directory — fail loud, never // continue. - if (options.workspaceRoot !== undefined) { - assertWorkspaceSafe({ workspacePath: cwd, workspaceRoot: options.workspaceRoot }); - } + assertWorkspacePolicy(cwd, options); if (!cachedEnv) { cachedEnv = getShellEnv(); @@ -264,9 +267,14 @@ export class ProcessManager extends EventEmitter { } /** - * Spawn an allowlisted CLI with stdin piped from `input`. This is the - * non-PTY subprocess surface for one-shot agent runs that must pass large - * prompts via stdin instead of argv. + * Spawn an allowlisted CLI with stdin piped from `input`. + * + * Contract for CLI providers such as Gemini: + * - validates `cwd` with the same `workspaceRoot` policy as `spawn` + * - resolves only ProcessManager-allowlisted agent commands or trusted shells + * - merges a filtered login-shell env with sanitized `extraEnv` + * - tracks `stdinMode: 'pipe'`, streams stdout and stderr as output, emits + * one exit event, and drops completed processes from the registry */ spawnWithStdin( type: AgentType, @@ -280,9 +288,7 @@ export class ProcessManager extends EventEmitter { const id = nanoid(); const outputMode = options.outputMode ?? 'normalized'; - if (options.workspaceRoot !== undefined) { - assertWorkspaceSafe({ workspacePath: cwd, workspaceRoot: options.workspaceRoot }); - } + assertWorkspacePolicy(cwd, options); if (!cachedEnv) { cachedEnv = getShellEnv();