feat(multimodel): add free-code runtime and provider status UI

This commit is contained in:
iliya 2026-04-02 10:22:11 +03:00
parent e26310870c
commit bae3609561
27 changed files with 2780 additions and 88 deletions

View file

@ -0,0 +1,825 @@
# Claude Multimodel Integration Plan
## Summary
`claude_team` will integrate with a single CLI runtime: `claude-multimodel`.
Inside that runtime, the app will track multiple provider states independently:
- `anthropic`
- `codex`
The dashboard will show one grouped status banner for the runtime and its providers.
That grouped banner should replace the current single-runtime dashboard banner, not sit beside a second provider banner.
Provider and model selection will happen at process launch time, not in the banner.
This avoids duplicating auth logic in `claude_team` and keeps `claude-multimodel` responsible
for provider login, token storage, and provider capability reporting.
## Core Decisions
### 1. Runtime model
There is one runtime:
- `claude-multimodel`
There are not multiple runtime binaries for this feature.
`Anthropic` and `Codex` are providers inside the same runtime.
Important consequence:
- there is no separate `Codex` binary to install
- there is no separate `Codex` version to display
- there is no separate `Codex` updater flow
### 2. Provider model
Provider status must be tracked independently.
Supported initial providers:
- `anthropic`
- `codex`
The user may be authenticated with both at the same time.
The UI must show both statuses independently without trying to choose a global active provider.
This provider model must also remain compatible with future providers that expose
multiple internal backend paths under one public provider id.
Example:
- public provider: `gemini`
- internal runtime backend: `api` or `cli`
In that scenario, `claude_team` should still treat `gemini` as one provider row.
Backend choice is diagnostic/runtime metadata inside the provider, not a second provider id.
Provider support must be treated per execution mode, not just globally.
At minimum, the integration needs to distinguish:
- interactive team launch / resume
- scheduled one-shot execution
Internal naming rule:
- app-level provider id: `codex`
- runtime/provider label in UI: `Codex`
- this maps to the OpenAI/Codex path inside `claude-multimodel`
- during migration, runtime-internal naming may still use `openai`; the bridge layer must normalize that to app-level `codex`
Backward-compatibility rule:
- existing persisted launches/schedules/teams that do not yet have `providerId` must default safely to `anthropic`
- missing `providerId` in old metadata must never block resume, launch, or schedule execution
### 3. Launch-time selection
Banner state is informational only.
Provider selection happens when launching a task/team/process:
- selected provider
- selected model
Future extension:
- per-teammate provider
- per-teammate model
Defaulting rule:
- for backward compatibility, flows with no explicit provider should default to `anthropic`
- provider defaulting must be explicit in code and persistence, not inferred from whichever UI tab/control happened to be last open
- model defaults must be provider-aware rather than using a single global default such as `opus`
- if a provider later adds internal backend selection, backend resolution must not silently change the app-level `providerId`
## Goals
- Show runtime status for `claude-multimodel`
- Show separate provider status for `Anthropic` and `Codex`
- Support login/logout/status checks through the runtime CLI
- Reuse the existing binary resolver and terminal-based login modal flow where possible
- Avoid reading runtime token/config internals directly from `claude_team`
- Keep current team launch flow extensible for provider/model selection
## Non-Goals
- No separate `Codex` installer
- No fake `Codex` download flow in the UI
- No separate `Codex` runtime version
- No full runtime abstraction layer right now
- No per-teammate model/provider implementation in this phase
## Architecture
### Runtime ownership
`claude-multimodel` owns:
- provider auth flows
- provider token storage
- provider auth verification
- provider model availability reporting
`claude_team` owns:
- UI rendering
- caching and refreshing status
- launch-time provider/model selection
- mapping runtime status into app-friendly DTOs
- reusing shared CLI probing helpers instead of duplicating binary/version logic
- ensuring per-process provider env/args are isolated and do not leak across parallel launches
- validating provider support for the concrete execution mode before spawn
### Service boundary in `claude_team`
Add a new main-process service:
- `src/main/services/runtime/ClaudeMultimodelBridgeService.ts`
Responsibilities:
- resolve runtime binary path
- get runtime version
- get provider auth status
- get provider model lists
- aggregate banner DTO
- trigger provider login/logout commands
This service should be separate from `CliInstallerService`.
Installer/update concerns and provider/auth concerns are different responsibilities.
However, the implementation should reuse existing low-level pieces where possible:
- `ClaudeBinaryResolver`
- existing terminal modal login pattern
- existing CLI version probing helpers, if they are extracted into a shared utility
But existing Claude-specific auth/model logic must not be reused as-is for provider rows:
- current installer auth probing is Anthropic-oriented
- current model parsing and display utilities are Claude-oriented
- provider rows must be driven by runtime provider data, not inferred from Claude-only helpers
The service should also enforce bounded status-check behavior:
- use timeouts for each runtime CLI command
- keep partial results if one command fails
- cache recent successful results briefly to avoid noisy repeated probes
## Required CLI Contract in `claude-multimodel`
The runtime should expose machine-readable commands.
### 1. Runtime version
Already available:
```bash
claude-multimodel --version
```
Used for:
- runtime version display in banner header
Version display rule:
- the app should normalize dev/build suffixes before rendering the banner header
- example:
- raw: `2.1.87-dev.20260401.t145625.sha38c09970 (Claude Code)`
- shown: `Claude 2.1.87`
### 2. Provider auth status
Add:
```bash
claude-multimodel auth status --json --provider all
```
Contract requirements:
- non-interactive only
- must never open a browser or prompt for user input
- must be side-effect free:
- no provider switching
- no token mutation unless the runtime explicitly performs a safe read-only refresh internally
- should return partial provider results when possible instead of failing the entire command on one provider-specific check
Example response:
```json
{
"schemaVersion": 1,
"providers": {
"anthropic": {
"supported": true,
"authenticated": true,
"authMethod": "oauth",
"verificationState": "verified",
"canLoginFromUi": true,
"capabilities": {
"teamLaunch": true,
"oneShot": true
}
},
"codex": {
"supported": true,
"authenticated": false,
"authMethod": null,
"verificationState": "verified",
"canLoginFromUi": true,
"capabilities": {
"teamLaunch": true,
"oneShot": true
}
}
}
}
```
Notes:
- no global `active` provider state belongs in this contract
- provider choice happens per launch request, not in global status
- `authenticated` means the runtime considers usable credentials/session state present for that provider
- `verificationState` must distinguish `verified` from `unknown` / `offline` / `error`
- `supported` means the current runtime build knows how to use that provider
- `capabilities` means the current runtime build knows which execution modes can use that provider
### 3. Provider model lists
Add:
```bash
claude-multimodel model list --json --provider all
```
Example response:
```json
{
"schemaVersion": 1,
"providers": {
"anthropic": {
"models": ["opus", "sonnet", "haiku"]
},
"codex": {
"models": ["gpt-5.3-codex", "gpt-5.4", "gpt-5.4-mini"]
}
}
}
```
This is intentionally separate from auth status so auth and capability failures can be handled independently.
Model listing should be available even when the provider is not currently authenticated, whenever the runtime can determine the model set statically.
### 4. Provider login
Add:
```bash
claude-multimodel auth login --provider anthropic
claude-multimodel auth login --provider codex
```
```bash
claude-multimodel auth logout --provider anthropic
claude-multimodel auth logout --provider codex
```
This allows `claude_team` to reuse runtime-managed login flows instead of implementing OAuth directly.
## Status Model in `claude_team`
Recommended DTO:
```ts
type ProviderId = 'anthropic' | 'codex';
interface ProviderStatus {
providerId: ProviderId;
displayName: string;
supported: boolean;
authenticated: boolean;
authMethod: 'oauth' | 'api_key' | 'external' | 'unknown' | null;
verificationState: 'verified' | 'unknown' | 'offline' | 'error';
statusMessage?: string | null;
models: string[];
canLoginFromUi: boolean;
capabilities: {
teamLaunch: boolean;
oneShot: boolean;
};
backend?: {
kind: string;
label: string;
endpointLabel?: string | null;
projectId?: string | null;
authMethodDetail?: string | null;
} | null;
}
interface ClaudeMultimodelDashboardStatus {
runtime: {
id: 'claude-multimodel';
installed: boolean;
version: string | null;
};
providers: ProviderStatus[];
}
```
## Dashboard Banner
One grouped banner should be shown.
It should replace the current dashboard runtime banner, not add a second top-level banner.
### Header
- runtime name/version, for example: `Claude 2.1.87`
- runtime auth-independent actions like `Extensions`
For `claude-multimodel` mode:
- keep the runtime version in the header
- hide runtime self-update UI
- hide binary path UI
### Provider rows
One row per provider:
- `Anthropic`
- `Codex`
Each row may show:
- authenticated state
- auth method
- verification state when status is uncertain
- model list if available
- backend diagnostics when the provider has internal backend resolution
- actions:
- `Login` when unauthenticated
- `Logout` when authenticated
- `Re-check` always
### Important UI rules
- Do not show a fake `Codex version`
- Do not show a separate `Codex installer`
- Do not show a fake `Download Codex` action
- Do not force a global provider choice from the banner
- If a provider has multiple internal backends, show them as provider diagnostics, not as separate provider rows
- Partial success must be visible:
- Anthropic ok, Codex missing
- Codex ok, Anthropic missing
## Launch Flow Changes
Current launch flow already supports a team-level `model`.
Current UI also already has a provider affordance in [TeamModelSelector.tsx](../../src/renderer/components/team/dialogs/TeamModelSelector.tsx), but its logic is still placeholder/coming-soon. That placeholder currently uses `openai`; it should be normalized or mapped to the app-level provider id `codex` during implementation.
Current code paths that must be kept in sync:
- shared types in [team.ts](../../src/shared/types/team.ts)
- HTTP launch parsing in [teams.ts](../../src/main/http/teams.ts)
- IPC launch/create validation in [teams.ts](../../src/main/ipc/teams.ts)
- persisted draft metadata in [TeamMetaStore.ts](../../src/main/services/team/TeamMetaStore.ts)
- scheduled one-shot config in [schedule.ts](../../src/shared/types/schedule.ts)
- scheduled execution in [ScheduledTaskExecutor.ts](../../src/main/services/schedule/ScheduledTaskExecutor.ts)
- provider/model UI helpers in [TeamModelSelector.tsx](../../src/renderer/components/team/dialogs/TeamModelSelector.tsx)
- model display parsing in [modelParser.ts](../../src/shared/utils/modelParser.ts)
- launch dialog state persistence in [LaunchTeamDialog.tsx](../../src/renderer/components/team/dialogs/LaunchTeamDialog.tsx)
- create dialog state persistence in [CreateTeamDialog.tsx](../../src/renderer/components/team/dialogs/CreateTeamDialog.tsx)
- saved draft restore in [teams.ts](../../src/main/ipc/teams.ts)
- launch param persistence in the renderer store
- slash-command metadata in [slashCommands.ts](../../src/shared/utils/slashCommands.ts)
Next step is to extend launch requests to include provider selection:
```ts
interface TeamLaunchRequest {
providerId?: 'anthropic' | 'codex';
model?: string;
}
```
Future-compatible extension for providers with internal backend preference:
```ts
interface TeamLaunchRequest {
providerId?: 'anthropic' | 'codex' | 'gemini';
model?: string;
providerOptions?: {
geminiBackendPreference?: 'auto' | 'api' | 'cli';
};
}
```
Provider selection must be applied only to the spawned child process environment/args.
It must not mutate a global app-wide runtime provider state.
This launch-time provider/model selection must be honored consistently across all spawn paths:
- manual team launch
- draft team launch that redirects into `createTeam`
- scheduled/background execution
- resume/retry/restart flows
- any other non-interactive process launch path that currently inherits team/model settings
Implementation rule:
- provider choice must be expressed via child-process-scoped args/env only
- conflicting provider env vars must be cleared for that child process
- one run using `codex` must not affect another run using `anthropic`
Future-compatible shape:
```ts
interface TeamLaunchRequest {
providerId?: 'anthropic' | 'codex';
model?: string;
memberProviders?: Record<string, 'anthropic' | 'codex'>;
memberModels?: Record<string, string>;
}
```
This allows future per-teammate routing without redesigning the API again.
Launch-time preflight validation should fail fast before spawning the child process when possible:
- selected provider is unsupported by the current runtime build
- selected provider is clearly unauthenticated
- selected model is clearly incompatible with the selected provider
These checks should produce actionable errors for the user, while still treating cached banner model lists as advisory rather than authoritative.
The same provider/model fields must also be added to the data that currently persists launch intent:
- `TeamLaunchRequest`
- `TeamCreateRequest` for draft-team fallback
- `TeamMetaFile` in `team.meta.json`
- `ScheduleLaunchConfig`
Migration rule:
- when old persisted data has no `providerId`, treat it as `anthropic`
- new writes should persist `providerId` explicitly
UI persistence rule:
- provider selection must be persisted independently from model selection
- remembered model state must be namespaced by provider, or reset safely on provider switch
- a previously remembered Anthropic model must not be auto-applied to Codex, and vice versa
Launch source-of-truth rule:
- explicit values from the current launch request win
- persisted metadata is fallback only
- banner/provider status is informational and must never silently override a launch request
- provider-internal backend resolution may inform diagnostics, but it must not overwrite explicit launch-time provider options
Runtime observation rule:
- launch-time `providerId` / `model` are the requested starting configuration
- observed runtime message metadata may later reflect a different actual model after in-session commands such as `/model`
- the app must not conflate "requested at launch" with "currently observed in session"
## Edge Cases
### Provider auth combinations
Must support all of these without breaking the banner:
- Anthropic authenticated, Codex unauthenticated
- Codex authenticated, Anthropic unauthenticated
- both authenticated
- neither authenticated
All of these must remain first-class supported states in the banner.
The same rule extends to future providers with internal backends:
- one provider row may be authenticated while one backend path is degraded
- the banner should show provider-level health plus backend diagnostics without splitting into two pseudo-providers
### Provider supported, but not for this execution mode
A provider may be authenticated and generally supported, but still unavailable for a specific path:
- available for one-shot scheduler runs, but not Agent Teams
- available for Agent Teams, but not one-shot
The banner and preflight validation must not flatten these into a single boolean.
Capability checks must use the concrete execution mode being launched.
For providers with internal backends, capability checks may also depend on the resolved backend:
- provider supported, backend `api` unsupported for this path
- provider supported, backend `cli` supported for this path
That still remains one provider decision surface in `claude_team`.
### Runtime ok, provider verify uncertain
If network verification fails:
- do not downgrade directly to `authenticated = false`
- prefer `verificationState = unknown` or `offline`
### No global active provider
Because provider choice happens per launch:
- the status contract must not imply one provider is globally selected
- a user can keep both `Anthropic` and `Codex` signed in simultaneously
- the banner is informational, not a provider selector
### Provider selection must not leak between runs
If one team/process launches with `codex` and another launches with `anthropic`:
- this must not mutate the banner into a single globally active provider state
- launch-specific provider state should stay attached to the launched process/team metadata
- dashboard status should continue to reflect provider availability/auth only
- child process env must explicitly clear incompatible provider-selection variables before spawn
### Managed flags vs custom CLI args
`claude_team` already supports raw `extraCliArgs` / custom CLI args.
Those args must not be allowed to silently override app-managed launch intent such as:
- provider selection
- model selection
- permission mode defaults owned by the app
- any future provider-routing flags/env
The implementation should either:
- reject conflicting custom args with a validation error, or
- define a strict precedence rule where app-managed provider/model flags always win
but it must not allow ambiguous mixed configuration.
### Provider-specific model normalization
Current UI and launch code contain Claude-specific model assumptions, including:
- `limitContext`
- automatic `[1m]` suffixing
- defaulting to Claude-family model ids such as `opus[1m]`
These rules must become provider-aware:
- Anthropic may keep Claude-specific context/model normalization
- Codex must not inherit Claude-only suffix rules
- switching provider must not silently rewrite a selected model into an invalid format for that provider
### Provider-specific selector state
Current selector state is Anthropic-biased:
- provider UI is currently hardcoded to Anthropic
- model options are static Anthropic options
- launch/create dialogs remember one global last-selected model
The implementation must make selector state provider-aware:
- provider list must come from supported runtime providers, with unsupported placeholders still disabled
- model options must come from the selected provider, not a shared static list
- switching provider must either preserve a valid provider-local previous choice or clear to that provider's default
- the UI must not display a stale Anthropic label/model when `codex` is selected
### Resume and restart semantics
If a team/run is resumed, retried, or restarted later:
- the persisted launch-time `providerId` and `model` must remain the default for that resumed run
- changing banner status or current UI defaults must not silently rewrite old run configuration
- explicit user overrides during a new launch are allowed, but implicit fallback to a different provider is not
The same rule applies to edited schedules and stored drafts:
- opening an old schedule/draft in a newer UI must not silently rewrite provider/model until the user explicitly saves changes
If a running session changes model interactively after launch:
- launch metadata should remain the historical requested starting state
- observed session model should come from runtime output/log parsing
- resume should continue from the actual session state instead of blindly reimposing old defaults unless the user explicitly requests a fresh launch
### Draft-team launch fallback
`claude_team` can redirect a launch request into `createTeam` when only `team.meta.json` exists.
That fallback must preserve the same provider/model intent:
- `providerId` must survive the launch -> create redirect
- draft metadata must not drop provider selection
- create-time validation must match launch-time validation
### Models unavailable
If model listing fails but auth status succeeds:
- keep provider visible
- show auth state
- omit or gray out model list
If model listing succeeds for an unauthenticated provider:
- still show the model list
- do not block model visibility on login state
### Provider-specific model display parsing
Current shared model display utilities are Claude-oriented.
The integration must not assume:
- every model id starts with `claude-`
- every provider model can be parsed into Claude family/version semantics
For non-Claude providers, the UI should fall back to generic provider/model labels unless a provider-specific parser is added intentionally.
This also applies to in-session command output:
- `/model` command output or equivalent runtime messages may describe state transitions differently per provider
- the UI should not assume Anthropic-specific phrasing when interpreting provider/model changes
If model lists differ by execution mode in a future runtime build:
- the contract should evolve via `schemaVersion`
- old app versions should continue to degrade safely instead of assuming one global model set
If model lists are temporarily unavailable for the selected provider in the launch dialog:
- the UI should still allow safe fallback behavior for backward-compatible Anthropic flows
- Codex flows should avoid inventing a default model id that did not come from the runtime or explicit user choice
### Model list is advisory, not launch authority
Provider model lists in the banner are helpful capability hints only:
- the actual launch path must still validate provider/model compatibility at spawn time
- cached model lists must not be treated as proof that a launch will succeed
- auth changes, subscription changes, or runtime updates may change model availability between refresh and launch
### Older runtime build
If the runtime does not yet support the new JSON commands:
- show runtime status normally
- show provider section as unavailable
- include a message like `Provider status not supported by current claude-multimodel build`
### Login/logout refresh behavior
After a login or logout flow in the terminal modal:
- the app must invalidate cached provider status
- the banner must refresh automatically
- a canceled login must not be treated as auth failure
Refresh handling must also be race-safe:
- older async refresh results must not overwrite newer ones
- concurrent login/logout/re-check actions must not leave the banner in a stale mixed state
- provider rows should expose a temporary loading/pending state while a provider-specific action is in flight
### Running teams during auth changes
If the user logs in, logs out, or changes provider credentials while teams are already running:
- the banner should refresh to reflect the new provider status
- already-running teams must not be implicitly reconfigured by the dashboard refresh itself
- launch-time provider/model metadata for existing runs should remain attached to those runs
Provider status changes should also avoid leaking sensitive details into UI logs:
- no token values
- no raw OAuth payloads
- only high-level auth method / verification state / actionable error text
### Runtime missing
If `claude-multimodel` is missing or not executable:
- show runtime-level failure in the banner header
- provider rows should be disabled or shown as unavailable
- do not render stale provider state as if it were fresh
### Slow or hanging status commands
If one or more runtime CLI status commands are slow or hang:
- the dashboard must not block indefinitely
- show the last good known state when available
- mark uncertain sections as stale/unknown instead of replacing them with false negatives
### Mixed command support during rollout
During migration, some runtime builds may support:
- version probing
- auth status JSON
- but not model list JSON
The app should degrade gracefully per capability instead of requiring all commands to work at once.
### Provider-specific status degradation
If one provider status check succeeds and another degrades:
- keep the successful provider row fully usable
- mark only the failing provider row as `unknown` / `error`
- do not collapse the whole banner into a global failure state
## Implementation Phases
### Phase 1. Runtime CLI contract
In `claude-multimodel`:
- add `auth status --json --provider all`
- add `model list --json --provider all`
- add `auth login --provider anthropic|codex`
- add `auth logout --provider anthropic|codex`
### Phase 2. Bridge service in `claude_team`
Add:
- `src/main/services/runtime/ClaudeMultimodelBridgeService.ts`
Implement:
- `getRuntimeVersion()`
- `getProviderAuthStatus()`
- `getProviderModels()`
- `getDashboardStatus()`
- `login(providerId)`
- `logout(providerId)`
The service should aggregate the banner DTO from multiple runtime CLI calls while keeping partial results usable.
### Phase 3. Banner integration
Refactor the dashboard CLI banner into a grouped runtime/provider banner:
- runtime header
- Anthropic provider row
- Codex provider row
This phase should also explicitly hide runtime path/update controls for `claude-multimodel` mode.
### Phase 4. Launch integration
Extend launch/create dialogs and IPC payloads:
- add `providerId`
- wire provider choice into spawned CLI args/env
- reuse the existing provider affordance in `TeamModelSelector` instead of creating a second provider selector
This phase must explicitly define the child-process env mapping for provider selection, including clearing conflicting provider flags.
It must also persist launch-time `providerId` alongside `model` in team/run metadata so existing runs remain inspectable after refresh.
It must update both the interactive team runtime path and the scheduler one-shot path.
Phase 4 should also explicitly hide or keep disabled unsupported placeholder providers in `TeamModelSelector` until their runtime support actually exists. In Phase 1/2/3, only `Anthropic` and `Codex` should move out of placeholder behavior.
### Phase 5. Future teammate-level routing
Later extension:
- `memberProviders`
- `memberModels`
This phase should happen only after team-level provider/model selection is stable.
It will likely require both `claude_team` changes and runtime support in `claude-multimodel` for teammate-level routing.
## Why This Plan
This plan keeps the ownership boundary clean:
- `claude-multimodel` remains the source of truth for auth and provider capabilities
- `claude_team` remains a UI/orchestration layer
It also avoids the two bad extremes:
- no duplicated OAuth/token logic in `claude_team`
- no premature heavy runtime abstraction layer
This is the smallest architecture that is still robust enough for:
- multi-provider status
- launch-time provider selection
- future per-agent provider/model routing
- shared runtime ownership without duplicating auth logic in `claude_team`

View file

@ -523,6 +523,9 @@ importers:
d3-force:
specifier: ^3.0.0
version: 3.0.0
lucide-react:
specifier: '>=0.300.0'
version: 0.577.0(react@18.3.1)
react:
specifier: ^18.0.0
version: 18.3.1
@ -15340,6 +15343,14 @@ snapshots:
chai: 5.3.3
tinyrainbow: 2.0.0
'@vitest/mocker@3.2.4(vite@5.4.21(@types/node@22.19.15)(sass@1.98.0)(terser@5.46.0))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 5.4.21(@types/node@22.19.15)(sass@1.98.0)(terser@5.46.0)
'@vitest/mocker@3.2.4(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))':
dependencies:
'@vitest/spy': 3.2.4
@ -19287,6 +19298,10 @@ snapshots:
dependencies:
yallist: 4.0.0
lucide-react@0.577.0(react@18.3.1):
dependencies:
react: 18.3.1
lucide-react@0.577.0(react@19.2.4):
dependencies:
react: 19.2.4
@ -22802,7 +22817,7 @@ snapshots:
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))
'@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@22.19.15)(sass@1.98.0)(terser@5.46.0))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4

View file

@ -17,6 +17,7 @@ import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import type { CliInstallerService } from '../services';
import { ClaudeBinaryResolver } from '../services/team/ClaudeBinaryResolver';
import type { CliInstallationStatus, IpcResult } from '@shared/types';
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
@ -111,5 +112,6 @@ async function handleInstall(_event: IpcMainInvokeEvent): Promise<IpcResult<void
function handleInvalidateStatus(_event: IpcMainInvokeEvent): IpcResult<void> {
cachedStatus = null;
ClaudeBinaryResolver.clearCache();
return { success: true, data: undefined };
}

View file

@ -286,6 +286,7 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V
'showDockIcon',
'theme',
'defaultTab',
'multimodelEnabled',
'claudeRootPath',
'agentLanguage',
'autoExpandAIGroups',
@ -328,6 +329,12 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V
}
result.defaultTab = value;
break;
case 'multimodelEnabled':
if (typeof value !== 'boolean') {
return { valid: false, error: 'general.multimodelEnabled must be a boolean' };
}
result.multimodelEnabled = value;
break;
case 'claudeRootPath':
if (value === null) {
result.claudeRootPath = null;

View file

@ -37,7 +37,9 @@ import https from 'https';
import { tmpdir } from 'os';
import { join, posix as pathPosix, win32 as pathWin32 } from 'path';
import { ClaudeMultimodelBridgeService } from '../runtime/ClaudeMultimodelBridgeService';
import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver';
import { getConfiguredCliFlavor, getCliFlavorUiOptions } from '../team/cliFlavor';
import type { CliInstallationStatus, CliInstallerProgress, CliPlatform } from '@shared/types';
import type { BrowserWindow } from 'electron';
@ -123,6 +125,18 @@ const DIAG_PATH_HEAD = 400;
const DIAG_HOME_PREVIEW = 120;
const DIAG_AUTH_STDOUT_TAIL = 160;
function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallationStatus {
return {
...status,
providers: status.providers.map((provider) => ({
...provider,
capabilities: { ...provider.capabilities },
backend: provider.backend ? { ...provider.backend } : null,
models: [...provider.models],
})),
};
}
// =============================================================================
// Helpers
// =============================================================================
@ -297,6 +311,7 @@ function resetGatherDiag(diag: CliInstallerStatusRunDiag): void {
export class CliInstallerService {
private mainWindow: BrowserWindow | null = null;
private installing = false;
private readonly multimodelBridgeService = new ClaudeMultimodelBridgeService();
private electronMetaForDiag(): Record<string, unknown> {
try {
@ -370,12 +385,48 @@ export class CliInstallerService {
return buildEnrichedEnv(binaryPath);
}
// ---------------------------------------------------------------------------
// Public: getStatus
// ---------------------------------------------------------------------------
async getStatus(): Promise<CliInstallationStatus> {
const result: CliInstallationStatus = {
private createInitialStatus(): CliInstallationStatus {
const flavor = getConfiguredCliFlavor();
const ui = getCliFlavorUiOptions(flavor);
const providers =
flavor === 'free-code'
? (
[
{
providerId: 'anthropic',
displayName: 'Anthropic',
},
{
providerId: 'codex',
displayName: 'Codex',
},
{
providerId: 'gemini',
displayName: 'Gemini',
},
] as const
).map((provider) => ({
...provider,
supported: false,
authenticated: false,
authMethod: null,
verificationState: 'unknown' as const,
statusMessage: 'Checking...',
models: [],
canLoginFromUi: true,
capabilities: {
teamLaunch: false,
oneShot: false,
},
backend: null,
}))
: [];
return {
flavor,
displayName: ui.displayName,
supportsSelfUpdate: ui.supportsSelfUpdate,
showVersionDetails: ui.showVersionDetails,
showBinaryPath: ui.showBinaryPath,
installed: false,
installedVersion: null,
binaryPath: null,
@ -383,7 +434,16 @@ export class CliInstallerService {
updateAvailable: false,
authLoggedIn: false,
authMethod: null,
providers,
};
}
// ---------------------------------------------------------------------------
// Public: getStatus
// ---------------------------------------------------------------------------
async getStatus(): Promise<CliInstallationStatus> {
const result = this.createInitialStatus();
// Run the actual status gathering with an overall timeout.
// On timeout, return whatever partial result was collected so far.
@ -437,6 +497,7 @@ export class CliInstallerService {
if (binaryPath) {
r.installed = true;
r.binaryPath = binaryPath;
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) });
try {
const { stdout } = await execCli(binaryPath, ['--version'], {
@ -451,13 +512,20 @@ export class CliInstallerService {
diag.versionError = getErrorMessage(err);
logger.warn('Failed to get CLI version:', diag.versionError);
}
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) });
// Auth and GCS version check are independent — run in parallel.
// Both mutate `r` directly so partial results survive the outer timeout.
await Promise.all([this.checkAuthStatus(binaryPath, r, diag), this.fetchLatestVersion(r)]);
await Promise.all([
this.checkAuthStatus(binaryPath, r, diag),
r.supportsSelfUpdate ? this.fetchLatestVersion(r) : Promise.resolve(),
]);
} else {
// No binary — still check latest version for "install" prompt
await this.fetchLatestVersion(r);
if (r.supportsSelfUpdate) {
await this.fetchLatestVersion(r);
}
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) });
}
}
@ -472,6 +540,31 @@ export class CliInstallerService {
result: CliInstallationStatus,
diag: CliInstallerStatusRunDiag
): Promise<void> {
if (result.flavor === 'free-code') {
try {
const providers = await this.multimodelBridgeService.getProviderStatuses(
binaryPath,
(providersSnapshot) => {
result.providers = providersSnapshot;
result.authLoggedIn = providersSnapshot.some((provider) => provider.authenticated);
result.authMethod =
providersSnapshot.find((provider) => provider.authenticated)?.authMethod ?? null;
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(result) });
}
);
result.providers = providers;
result.authLoggedIn = providers.some((provider) => provider.authenticated);
result.authMethod =
providers.find((provider) => provider.authenticated)?.authMethod ?? null;
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(result) });
} catch (error) {
const msg = getErrorMessage(error);
diag.authLastError = msg;
logger.warn(`Provider status check failed for claude-multimodel: ${msg}`);
}
return;
}
const doCheck = async (): Promise<void> => {
for (let authAttempt = 1; authAttempt <= AUTH_STATUS_MAX_RETRIES; authAttempt++) {
diag.authAttempts = authAttempt;
@ -559,6 +652,13 @@ export class CliInstallerService {
// ---------------------------------------------------------------------------
async install(): Promise<void> {
if (!getCliFlavorUiOptions(getConfiguredCliFlavor()).supportsSelfUpdate) {
const error = 'Updates are disabled for the configured free-code runtime.';
logger.warn(error);
this.sendProgress({ type: 'error', error });
return;
}
if (this.installing) {
this.sendProgress({ type: 'error', error: 'Installation already in progress' });
return;

View file

@ -204,6 +204,7 @@ export interface GeneralConfig {
showDockIcon: boolean;
theme: 'dark' | 'light' | 'system';
defaultTab: 'dashboard' | 'last-session';
multimodelEnabled: boolean;
claudeRootPath: string | null;
agentLanguage: string;
autoExpandAIGroups: boolean;
@ -290,6 +291,7 @@ const DEFAULT_CONFIG: AppConfig = {
showDockIcon: true,
theme: 'dark',
defaultTab: 'dashboard',
multimodelEnabled: true,
claudeRootPath: null,
agentLanguage: 'system',
autoExpandAIGroups: false,

View file

@ -0,0 +1,299 @@
import { execCli } from '@main/utils/childProcess';
import { buildEnrichedEnv } from '@main/utils/cliEnv';
import {
getCachedShellEnv,
getShellPreferredHome,
resolveInteractiveShellEnv,
} from '@main/utils/shellEnv';
import { createLogger } from '@shared/utils/logger';
import type { CliProviderId, CliProviderStatus } from '@shared/types';
import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
const logger = createLogger('ClaudeMultimodelBridgeService');
const PROVIDER_STATUS_TIMEOUT_MS = 10_000;
const PROVIDER_MODELS_TIMEOUT_MS = 10_000;
interface ProviderStatusCommandResponse {
schemaVersion?: number;
providers?: Record<
string,
{
supported?: boolean;
authenticated?: boolean;
authMethod?: string | null;
verificationState?: 'verified' | 'unknown' | 'offline' | 'error';
canLoginFromUi?: boolean;
statusMessage?: string | null;
capabilities?: {
teamLaunch?: boolean;
oneShot?: boolean;
};
backend?: {
kind?: string;
label?: string;
endpointLabel?: string | null;
projectId?: string | null;
authMethodDetail?: string | null;
} | null;
}
>;
}
interface ProviderModelsCommandResponse {
schemaVersion?: number;
providers?: Record<
string,
{
models?: Array<string | { id?: string; label?: string; description?: string }>;
}
>;
}
const ORDERED_PROVIDER_IDS: CliProviderId[] = ['anthropic', 'codex', 'gemini'];
function extractJsonObject<T>(raw: string): T {
const trimmed = raw.trim();
try {
return JSON.parse(trimmed) as T;
} catch {
const start = trimmed.indexOf('{');
const end = trimmed.lastIndexOf('}');
if (start >= 0 && end > start) {
return JSON.parse(trimmed.slice(start, end + 1)) as T;
}
throw new Error('No JSON object found in CLI output');
}
}
function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStatus {
return {
providerId,
displayName:
providerId === 'anthropic' ? 'Anthropic' : providerId === 'codex' ? 'Codex' : 'Gemini',
supported: false,
authenticated: false,
authMethod: null,
verificationState: 'unknown',
statusMessage: null,
models: [],
canLoginFromUi: true,
capabilities: {
teamLaunch: false,
oneShot: false,
},
backend: null,
};
}
function extractModelIds(
models: Array<string | { id?: string; label?: string; description?: string }> | undefined
): string[] {
if (!models) {
return [];
}
return models.flatMap((model) => {
if (typeof model === 'string') {
return model;
}
if (typeof model?.id === 'string' && model.id.trim().length > 0) {
return model.id.trim();
}
return [];
});
}
export class ClaudeMultimodelBridgeService {
private buildCliEnv(binaryPath: string): NodeJS.ProcessEnv {
const shellEnv = getCachedShellEnv() ?? {};
const home =
getShellPreferredHome() || shellEnv.HOME || process.env.HOME || process.env.USERPROFILE;
const env = {
...buildEnrichedEnv(binaryPath),
...shellEnv,
};
if (home) {
env.HOME = home;
}
return env;
}
private buildProviderCliEnv(binaryPath: string, providerId: CliProviderId): NodeJS.ProcessEnv {
const env = { ...this.buildCliEnv(binaryPath) };
delete env.CLAUDE_CODE_USE_OPENAI;
delete env.CLAUDE_CODE_USE_BEDROCK;
delete env.CLAUDE_CODE_USE_VERTEX;
delete env.CLAUDE_CODE_USE_FOUNDRY;
delete env.CLAUDE_CODE_USE_GEMINI;
if (providerId === 'codex') {
env.CLAUDE_CODE_USE_OPENAI = '1';
} else if (providerId === 'gemini') {
env.CLAUDE_CODE_USE_GEMINI = '1';
}
return env;
}
private async buildGeminiStatus(binaryPath: string): Promise<CliProviderStatus> {
const provider = createDefaultProviderStatus('gemini');
const env = this.buildProviderCliEnv(binaryPath, 'gemini');
try {
const { stdout } = await execCli(
binaryPath,
['model', 'list', '--json', '--provider', 'all'],
{
timeout: PROVIDER_MODELS_TIMEOUT_MS,
env,
}
);
const parsed = extractJsonObject<ProviderModelsCommandResponse>(stdout);
const models = extractModelIds(parsed.providers?.gemini?.models);
if (models.length > 0) {
provider.supported = true;
provider.models = models;
provider.capabilities = {
teamLaunch: true,
oneShot: true,
};
}
} catch (error) {
logger.warn(
`Gemini model list unavailable: ${error instanceof Error ? error.message : String(error)}`
);
}
const authState = await resolveGeminiRuntimeAuth(env);
if (authState.authenticated) {
provider.authenticated = true;
provider.authMethod =
authState.authMethod === 'adc_authorized_user' ||
authState.authMethod === 'adc_service_account'
? `gemini_${authState.authMethod}`
: authState.authMethod;
provider.verificationState = 'verified';
provider.statusMessage = null;
if (authState.authMethod === 'cli_oauth_personal') {
provider.backend = {
kind: 'cli',
label: 'Gemini CLI',
endpointLabel: 'Code Assist (cloudcode-pa.googleapis.com/v1internal)',
projectId: authState.projectId,
authMethodDetail: authState.authMethod,
};
}
return provider;
}
provider.statusMessage =
authState.statusMessage ?? 'Set GEMINI_API_KEY or Google ADC to use Gemini.';
return provider;
}
async getProviderStatuses(
binaryPath: string,
onUpdate?: (providers: CliProviderStatus[]) => void
): Promise<CliProviderStatus[]> {
await resolveInteractiveShellEnv();
const env = this.buildCliEnv(binaryPath);
const [statusResult, modelsResult] = await Promise.allSettled([
execCli(binaryPath, ['auth', 'status', '--json', '--provider', 'all'], {
timeout: PROVIDER_STATUS_TIMEOUT_MS,
env,
}),
execCli(binaryPath, ['model', 'list', '--json', '--provider', 'all'], {
timeout: PROVIDER_MODELS_TIMEOUT_MS,
env,
}),
]);
const providers = new Map<CliProviderId, CliProviderStatus>(
ORDERED_PROVIDER_IDS.map((providerId) => [
providerId,
createDefaultProviderStatus(providerId),
])
);
if (statusResult.status === 'fulfilled') {
try {
const parsed = extractJsonObject<ProviderStatusCommandResponse>(statusResult.value.stdout);
for (const providerId of ORDERED_PROVIDER_IDS.filter((id) => id !== 'gemini')) {
const runtimeStatus = parsed.providers?.[providerId];
if (!runtimeStatus) continue;
providers.set(providerId, {
...providers.get(providerId)!,
supported: runtimeStatus.supported === true,
authenticated: runtimeStatus.authenticated === true,
authMethod: runtimeStatus.authMethod ?? null,
verificationState: runtimeStatus.verificationState ?? 'unknown',
statusMessage: runtimeStatus.statusMessage ?? null,
canLoginFromUi: runtimeStatus.canLoginFromUi !== false,
capabilities: {
teamLaunch: runtimeStatus.capabilities?.teamLaunch === true,
oneShot: runtimeStatus.capabilities?.oneShot === true,
},
backend: runtimeStatus.backend?.kind
? {
kind: runtimeStatus.backend.kind,
label: runtimeStatus.backend.label ?? runtimeStatus.backend.kind,
endpointLabel: runtimeStatus.backend.endpointLabel ?? null,
projectId: runtimeStatus.backend.projectId ?? null,
authMethodDetail: runtimeStatus.backend.authMethodDetail ?? null,
}
: null,
});
onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!));
}
} catch (error) {
logger.warn(
`Failed to parse provider auth status JSON: ${
error instanceof Error ? error.message : String(error)
}`
);
}
} else {
const message =
statusResult.reason instanceof Error
? statusResult.reason.message
: String(statusResult.reason);
logger.warn(`Provider auth status unavailable: ${message}`);
for (const providerId of ORDERED_PROVIDER_IDS) {
providers.set(providerId, {
...providers.get(providerId)!,
statusMessage: 'Provider status not supported by current claude-multimodel build',
});
onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!));
}
}
if (modelsResult.status === 'fulfilled') {
try {
const parsed = extractJsonObject<ProviderModelsCommandResponse>(modelsResult.value.stdout);
for (const providerId of ORDERED_PROVIDER_IDS.filter((id) => id !== 'gemini')) {
const runtimeModels = extractModelIds(parsed.providers?.[providerId]?.models);
if (runtimeModels.length === 0) continue;
providers.set(providerId, {
...providers.get(providerId)!,
models: runtimeModels,
});
onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!));
}
} catch (error) {
logger.warn(
`Failed to parse provider models JSON: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
providers.set('gemini', await this.buildGeminiStatus(binaryPath));
onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!));
return ORDERED_PROVIDER_IDS.map((providerId) => providers.get(providerId)!);
}
}

View file

@ -0,0 +1,113 @@
import * as fs from 'fs';
import * as path from 'path';
export type GeminiGlobalConfig = {
geminiBackendPreference?: 'auto' | 'api' | 'cli';
geminiResolvedBackend?: 'api' | 'cli';
geminiLastAuthMethod?: string;
geminiProjectId?: string;
};
export type GeminiRuntimeAuthState = {
authenticated: boolean;
authMethod: string | null;
resolvedBackend: 'auto' | 'api' | 'cli';
projectId: string | null;
statusMessage: string | null;
};
export async function readGeminiGlobalConfig(
env: NodeJS.ProcessEnv
): Promise<GeminiGlobalConfig | null> {
const home = env.HOME?.trim() || env.USERPROFILE?.trim();
const configDir = env.CLAUDE_CONFIG_DIR?.trim();
const candidates = configDir
? [path.join(configDir, '.config.json')]
: home
? [path.join(home, '.claude', '.config.json'), path.join(home, '.claude.json')]
: [];
for (const candidate of candidates) {
try {
const raw = await fs.promises.readFile(candidate, 'utf8');
return JSON.parse(raw) as GeminiGlobalConfig;
} catch {
continue;
}
}
return null;
}
export async function resolveGeminiRuntimeAuth(
env: NodeJS.ProcessEnv
): Promise<GeminiRuntimeAuthState> {
const config = await readGeminiGlobalConfig(env);
const resolvedBackend =
env.CLAUDE_CODE_GEMINI_BACKEND?.trim() ||
config?.geminiResolvedBackend?.trim() ||
config?.geminiBackendPreference?.trim() ||
'auto';
const authMethod = config?.geminiLastAuthMethod?.trim() ?? null;
const projectId =
env.GOOGLE_CLOUD_PROJECT?.trim() ||
env.GOOGLE_CLOUD_PROJECT_ID?.trim() ||
env.GCLOUD_PROJECT?.trim() ||
config?.geminiProjectId?.trim() ||
null;
const hasGeminiApiKey = Boolean(env.GEMINI_API_KEY?.trim());
if (hasGeminiApiKey) {
return {
authenticated: true,
authMethod: 'api_key',
resolvedBackend:
resolvedBackend === 'api' || resolvedBackend === 'cli' ? resolvedBackend : 'auto',
projectId,
statusMessage: null,
};
}
if ((authMethod === 'adc_authorized_user' || authMethod === 'adc_service_account') && projectId) {
return {
authenticated: true,
authMethod,
resolvedBackend:
resolvedBackend === 'api' || resolvedBackend === 'cli' ? resolvedBackend : 'auto',
projectId,
statusMessage: null,
};
}
if (authMethod === 'cli_oauth_personal' && resolvedBackend === 'cli') {
return {
authenticated: true,
authMethod,
resolvedBackend: 'cli',
projectId,
statusMessage: null,
};
}
if (authMethod === 'cli_oauth_personal') {
return {
authenticated: false,
authMethod,
resolvedBackend:
resolvedBackend === 'api' || resolvedBackend === 'cli' ? resolvedBackend : 'auto',
projectId,
statusMessage:
'Gemini CLI OAuth was detected, but the active Gemini backend is not set to cli.',
};
}
return {
authenticated: false,
authMethod,
resolvedBackend:
resolvedBackend === 'api' || resolvedBackend === 'cli' ? resolvedBackend : 'auto',
projectId,
statusMessage:
'Gemini provider is not configured for runtime use. Set GEMINI_API_KEY or Google ADC credentials (plus GOOGLE_CLOUD_PROJECT when needed) and retry.',
};
}

View file

@ -0,0 +1,33 @@
import type { TeamProviderId } from '@shared/types';
const THIRD_PARTY_PROVIDER_ENV_KEYS = [
'CLAUDE_CODE_USE_OPENAI',
'CLAUDE_CODE_USE_BEDROCK',
'CLAUDE_CODE_USE_VERTEX',
'CLAUDE_CODE_USE_FOUNDRY',
'CLAUDE_CODE_USE_GEMINI',
] as const;
export function applyProviderRuntimeEnv(
env: NodeJS.ProcessEnv,
providerId: TeamProviderId | undefined
): NodeJS.ProcessEnv {
const resolvedProvider: TeamProviderId =
providerId === 'codex' || providerId === 'gemini' ? providerId : 'anthropic';
for (const key of THIRD_PARTY_PROVIDER_ENV_KEYS) {
env[key] = undefined;
}
if (resolvedProvider === 'codex') {
env.CLAUDE_CODE_USE_OPENAI = '1';
} else if (resolvedProvider === 'gemini') {
env.CLAUDE_CODE_USE_GEMINI = '1';
}
return env;
}
export function resolveTeamProviderId(providerId: TeamProviderId | undefined): TeamProviderId {
return providerId === 'codex' || providerId === 'gemini' ? providerId : 'anthropic';
}

View file

@ -13,6 +13,7 @@ import { buildEnrichedEnv } from '@main/utils/cliEnv';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import { createLogger } from '@shared/utils/logger';
import { applyProviderRuntimeEnv } from '../runtime/providerRuntimeEnv';
import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver';
import type { ScheduleLaunchConfig, ScheduleRun } from '@shared/types';
@ -101,11 +102,16 @@ export class ScheduledTaskExecutor {
logger.info(`[${request.runId}] Spawning: ${binaryPath} ${args.join(' ')}`);
const env = applyProviderRuntimeEnv(
{ ...buildEnrichedEnv(binaryPath), ...shellEnv, CLAUDECODE: undefined },
request.config.providerId
);
const child = spawnCli(binaryPath, args, {
cwd: request.config.cwd,
// shellEnv spread after buildEnrichedEnv ensures freshly-resolved values
// take precedence over the cached snapshot inside buildEnrichedEnv.
env: { ...buildEnrichedEnv(binaryPath), ...shellEnv, CLAUDECODE: undefined },
env,
stdio: ['ignore', 'pipe', 'pipe'],
});

View file

@ -3,6 +3,8 @@ import { getShellPreferredHome, resolveInteractiveShellEnv } from '@main/utils/s
import * as fs from 'fs';
import * as path from 'path';
import { getConfiguredCliFlavor } from './cliFlavor';
async function isExecutable(filePath: string): Promise<boolean> {
if (process.platform === 'win32') {
try {
@ -165,6 +167,28 @@ async function resolveFromExplicitPath(inputPath: string): Promise<string | null
return null;
}
async function resolveFromCandidateList(candidates: string[]): Promise<string | null> {
for (const candidate of candidates) {
if (await isExecutable(candidate)) {
return candidate;
}
}
return null;
}
function getRepoLocalCliCandidates(): string[] {
if (process.platform === 'win32') {
return [];
}
const repoRoot = process.cwd();
return [
path.resolve(repoRoot, '..', 'free-code-gemini-research', 'cli'),
path.resolve(repoRoot, '..', 'free-code-gemini-research', 'cli-dev'),
path.resolve(repoRoot, '..', 'free-code-gemini-research', 'dist', 'cli'),
];
}
let cachedPath: string | null | undefined;
/** Timestamp of last successful cache verification (ms). */
@ -213,6 +237,7 @@ export class ClaudeBinaryResolver {
private static async runResolve(): Promise<string | null> {
await resolveInteractiveShellEnv();
const enrichedPath = buildMergedCliPath(null);
const flavor = getConfiguredCliFlavor();
const overrideRaw = process.env.CLAUDE_CLI_PATH?.trim();
if (overrideRaw) {
@ -229,6 +254,19 @@ export class ClaudeBinaryResolver {
}
}
if (flavor === 'free-code') {
const repoLocalCli = await resolveFromCandidateList(getRepoLocalCliCandidates());
if (repoLocalCli) {
cachedPath = repoLocalCli;
cacheVerifiedAt = Date.now();
return cachedPath;
}
// Free-code mode is explicit. If the configured local runtime is missing,
// fail closed instead of silently falling back to a different CLI.
return null;
}
const baseBinaryName = 'claude';
const fromPath = await resolveFromPathEnv(baseBinaryName, enrichedPath);
if (fromPath) {

View file

@ -0,0 +1,43 @@
import type { CliFlavor, CliFlavorUiOptions } from '@shared/types';
import { configManager } from '../infrastructure/ConfigManager';
export const DEFAULT_CLI_FLAVOR: CliFlavor = 'free-code';
function parseFlavorOverride(raw: string | undefined): CliFlavor | null {
const trimmed = raw?.trim();
if (trimmed === 'claude' || trimmed === 'free-code') {
return trimmed;
}
return null;
}
export function getConfiguredCliFlavor(): CliFlavor {
const envOverride = parseFlavorOverride(process.env.CLAUDE_TEAM_CLI_FLAVOR);
if (envOverride) {
return envOverride;
}
const multimodelEnabled = configManager.getConfig().general.multimodelEnabled;
return multimodelEnabled ? 'free-code' : 'claude';
}
export function getCliFlavorUiOptions(flavor: CliFlavor): CliFlavorUiOptions {
switch (flavor) {
case 'free-code':
return {
displayName: 'free-code-gemini-research',
supportsSelfUpdate: false,
showVersionDetails: false,
showBinaryPath: false,
};
case 'claude':
default:
return {
displayName: 'Claude CLI',
supportsSelfUpdate: true,
showVersionDetails: true,
showBinaryPath: true,
};
}
}

View file

@ -837,8 +837,17 @@ const electronAPI: ElectronAPI = {
deleteDraft: async (teamName: string) => {
return invokeIpcWithResult<void>(TEAM_DELETE_DRAFT, teamName);
},
prepareProvisioning: async (cwd?: string) => {
return invokeIpcWithResult<TeamProvisioningPrepareResult>(TEAM_PREPARE_PROVISIONING, cwd);
prepareProvisioning: async (
cwd?: string,
providerId?: TeamLaunchRequest['providerId'],
providerIds?: TeamLaunchRequest['providerId'][]
) => {
return invokeIpcWithResult<TeamProvisioningPrepareResult>(
TEAM_PREPARE_PROVISIONING,
cwd,
providerId,
providerIds
);
},
createTeam: async (request: TeamCreateRequest) => {
return invokeIpcWithResult<TeamCreateResponse>(TEAM_CREATE, request);

View file

@ -10,6 +10,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api, isElectronMode } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { SettingsToggle } from '@renderer/components/settings/components';
import { TerminalLogPanel } from '@renderer/components/terminal/TerminalLogPanel';
import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
@ -24,11 +26,14 @@ import {
HelpCircle,
Loader2,
LogIn,
LogOut,
Puzzle,
RefreshCw,
Terminal,
} from 'lucide-react';
import type { CliInstallationStatus, CliProviderId, CliProviderStatus } from '@shared/types';
// =============================================================================
// Border color by state
// =============================================================================
@ -141,7 +146,7 @@ const CliCheckingSpinner = ({
/>
<div>
<span className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
Checking Claude CLI...
Checking AI Providers...
</span>
{showHint && (
<p className="mt-0.5 text-xs" style={{ color: 'var(--color-text-muted)', opacity: 0.7 }}>
@ -162,22 +167,211 @@ interface InstalledBannerProps {
cliStatusLoading: boolean;
cliStatusError: string | null;
isBusy: boolean;
multimodelEnabled: boolean;
multimodelBusy: boolean;
onInstall: () => void;
onRefresh: () => void;
onMultimodelToggle: (enabled: boolean) => void;
onProviderLogin: (providerId: CliProviderId) => void;
onProviderLogout: (providerId: CliProviderId) => void;
variant: BannerVariant;
}
function getProviderLabel(providerId: CliProviderId): string {
switch (providerId) {
case 'anthropic':
return 'Anthropic';
case 'codex':
return 'Codex';
case 'gemini':
return 'Gemini';
}
}
function getProviderTerminalCommand(providerId: CliProviderId): {
args: string[];
env?: Record<string, string>;
} {
if (providerId === 'gemini') {
return {
args: ['login'],
env: { CLAUDE_CODE_USE_GEMINI: '1' },
};
}
return {
args: ['auth', 'login', '--provider', providerId],
};
}
function getProviderTerminalLogoutCommand(providerId: CliProviderId): {
args: string[];
env?: Record<string, string>;
} {
if (providerId === 'gemini') {
return {
args: ['logout'],
env: { CLAUDE_CODE_USE_GEMINI: '1' },
};
}
return {
args: ['auth', 'logout', '--provider', providerId],
};
}
function formatProviderStatus(provider: CliProviderStatus): string {
if (!provider.supported) {
return provider.statusMessage ?? 'Unavailable in current runtime';
}
if (provider.authenticated) {
return provider.authMethod ? `Authenticated via ${provider.authMethod}` : 'Authenticated';
}
if (provider.verificationState === 'offline') {
return provider.statusMessage ?? 'Unable to verify';
}
return provider.statusMessage ?? 'Not connected';
}
function formatProviderModels(provider: CliProviderStatus): string | null {
return provider.models.length > 0 ? provider.models.join(', ') : null;
}
function formatModelBadgeLabel(providerId: CliProviderId, model: string): string {
if (providerId === 'anthropic') {
return model.replace(/^claude-/, '');
}
if (providerId === 'codex') {
return model.replace(/^gpt-/, '');
}
if (providerId === 'gemini') {
return model.replace(/^gemini-/, '');
}
return model;
}
function ModelBadges({
providerId,
models,
}: {
providerId: CliProviderId;
models: string[];
}): React.JSX.Element {
return (
<div className="flex flex-wrap gap-1.5">
{models.map((model) => (
<span
key={model}
className="rounded-md border px-1.5 py-px font-mono text-[10px] leading-4"
style={{
borderColor: 'var(--color-border-subtle)',
backgroundColor: 'rgba(255, 255, 255, 0.03)',
color: 'var(--color-text-secondary)',
}}
>
{formatModelBadgeLabel(providerId, model)}
</span>
))}
</div>
);
}
function formatRuntimeLabel(
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>
): string | null {
if (cliStatus.flavor === 'free-code') {
return null;
}
return cliStatus.showVersionDetails && cliStatus.installedVersion
? `${cliStatus.displayName} v${cliStatus.installedVersion ?? 'unknown'}`
: cliStatus.displayName;
}
function formatRuntimeAuthSummary(
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>
): string | null {
if (cliStatus.flavor === 'free-code' && cliStatus.providers.length > 0) {
if (
cliStatus.providers.every(
(provider) => provider.statusMessage === 'Checking...' && !provider.authenticated
)
) {
return 'Checking providers...';
}
const supportedProviders = cliStatus.providers.filter((provider) => provider.supported);
const denominator =
supportedProviders.length > 0 ? supportedProviders.length : cliStatus.providers.length;
const connected = (
supportedProviders.length > 0 ? supportedProviders : cliStatus.providers
).filter((provider) => provider.authenticated).length;
return `Providers: ${connected}/${denominator} connected`;
}
if (cliStatus.authLoggedIn) {
return 'Authenticated';
}
return null;
}
function createLoadingMultimodelStatus(): CliInstallationStatus {
const providers: CliProviderStatus[] = [
{ providerId: 'anthropic' as const, displayName: 'Anthropic' },
{ providerId: 'codex' as const, displayName: 'Codex' },
{ providerId: 'gemini' as const, displayName: 'Gemini' },
].map((provider) => ({
...provider,
supported: false,
authenticated: false,
authMethod: null,
verificationState: 'unknown' as const,
statusMessage: 'Checking...',
models: [],
canLoginFromUi: true,
capabilities: {
teamLaunch: false,
oneShot: false,
},
backend: null,
}));
return {
flavor: 'free-code',
displayName: 'free-code-gemini-research',
supportsSelfUpdate: false,
showVersionDetails: false,
showBinaryPath: false,
installed: true,
installedVersion: null,
binaryPath: null,
latestVersion: null,
updateAvailable: false,
authLoggedIn: false,
authMethod: null,
providers,
};
}
const InstalledBanner = ({
cliStatus,
cliStatusLoading,
cliStatusError,
isBusy,
multimodelEnabled,
multimodelBusy,
onInstall,
onRefresh,
onMultimodelToggle,
onProviderLogin,
onProviderLogout,
variant,
}: InstalledBannerProps): React.JSX.Element => {
const openExtensionsTab = useStore((s) => s.openExtensionsTab);
const styles = VARIANT_STYLES[variant];
const runtimeLabel = formatRuntimeLabel(cliStatus);
const runtimeAuthSummary = formatRuntimeAuthSummary(cliStatus);
return (
<div
@ -189,12 +383,14 @@ const InstalledBanner = ({
<Terminal className="size-4 shrink-0" style={{ color: 'var(--color-text-muted)' }} />
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm" style={{ color: 'var(--color-text)' }}>
Claude CLI v{cliStatus.installedVersion ?? 'unknown'}
</span>
{runtimeLabel && (
<span className="text-sm" style={{ color: 'var(--color-text)' }}>
{runtimeLabel}
</span>
)}
{/* Update / Check for Updates — inline next to version */}
{cliStatus.updateAvailable ? (
{cliStatus.supportsSelfUpdate && cliStatus.updateAvailable ? (
<button
onClick={onInstall}
disabled={isBusy}
@ -204,7 +400,7 @@ const InstalledBanner = ({
<Download className="size-3" />
Update to v{cliStatus.latestVersion}
</button>
) : (
) : cliStatus.supportsSelfUpdate ? (
<button
onClick={onRefresh}
disabled={cliStatusLoading}
@ -214,15 +410,15 @@ const InstalledBanner = ({
<RefreshCw className={cliStatusLoading ? 'size-3 animate-spin' : 'size-3'} />
{cliStatusLoading ? 'Checking...' : 'Check for Updates'}
</button>
)}
) : null}
{cliStatus.authLoggedIn && (
{runtimeAuthSummary && (
<span className="text-xs" style={{ color: '#4ade80' }}>
Authenticated
{runtimeAuthSummary}
</span>
)}
</div>
{cliStatus.binaryPath && (
{cliStatus.showBinaryPath && cliStatus.binaryPath && (
<button
className="truncate font-mono text-xs hover:underline"
style={{ color: 'var(--color-text-muted)' }}
@ -235,23 +431,146 @@ const InstalledBanner = ({
</div>
</div>
{/* Extensions button — only when installed + authenticated */}
{cliStatus.authLoggedIn && (
<button
onClick={openExtensionsTab}
className="flex shrink-0 items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/5"
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
>
<Puzzle className="size-3.5" />
Extensions
</button>
)}
<div className="flex shrink-0 items-center gap-8">
<div className="flex items-center gap-2">
<span className="text-xs font-medium" style={{ color: 'var(--color-text-secondary)' }}>
Multimodel
</span>
{multimodelEnabled && (
<span
className="rounded-full border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em]"
style={{
borderColor: 'rgba(245, 158, 11, 0.35)',
backgroundColor: 'rgba(245, 158, 11, 0.1)',
color: '#fbbf24',
}}
>
Beta
</span>
)}
<SettingsToggle
enabled={multimodelEnabled}
onChange={onMultimodelToggle}
disabled={isBusy || cliStatusLoading || multimodelBusy}
/>
</div>
{/* Extensions button — only when installed + authenticated */}
{cliStatus.authLoggedIn && (
<button
onClick={openExtensionsTab}
className="flex shrink-0 items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/5"
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
>
<Puzzle className="size-3.5" />
Extensions
</button>
)}
</div>
</div>
{cliStatusError && !cliStatusLoading && (
<p className="mt-2 text-xs" style={{ color: '#f87171' }}>
Failed to check for updates. Check your network connection and try again.
</p>
)}
{cliStatus.providers.length > 0 && (
<div
className="mt-3 space-y-2 border-t pt-3"
style={{ borderColor: 'var(--color-border-subtle)' }}
>
{cliStatus.providers.map((provider) => {
const statusText = formatProviderStatus(provider);
const actionDisabled = isBusy || !cliStatus.binaryPath;
return (
<div
key={provider.providerId}
className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-2 rounded-md px-2 py-2"
style={{ backgroundColor: 'rgba(255, 255, 255, 0.02)' }}
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium" style={{ color: 'var(--color-text)' }}>
{provider.displayName}
</span>
<span
className="text-xs"
style={{
color: provider.authenticated ? '#4ade80' : 'var(--color-text-muted)',
}}
>
{statusText}
</span>
</div>
<div
className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px]"
style={{ color: 'var(--color-text-muted)' }}
>
{provider.backend?.label && (
<span>
Backend: {provider.backend.label}
{provider.backend.endpointLabel
? ` (${provider.backend.endpointLabel})`
: ''}
</span>
)}
{provider.models.length === 0 && (
<span>Models unavailable for this runtime build</span>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{provider.authenticated ? (
<button
onClick={() => onProviderLogout(provider.providerId)}
disabled={actionDisabled}
className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
style={{
borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)',
}}
>
<LogOut className="size-3" />
Logout
</button>
) : provider.canLoginFromUi ? (
<button
onClick={() => onProviderLogin(provider.providerId)}
disabled={actionDisabled}
className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
style={{
borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)',
}}
>
<LogIn className="size-3" />
Login
</button>
) : null}
<button
onClick={onRefresh}
disabled={cliStatusLoading}
className="flex items-center gap-1 rounded-md border px-1.5 py-[3px] text-[10px] transition-colors hover:bg-white/5 disabled:opacity-50"
style={{
borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)',
}}
title={`Re-check ${provider.displayName}`}
>
<RefreshCw
className={cliStatusLoading ? 'size-[11px] animate-spin' : 'size-[11px]'}
/>
</button>
</div>
{provider.models.length > 0 && (
<div className="col-span-2">
<ModelBadges providerId={provider.providerId} models={provider.models} />
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
};
@ -262,6 +581,8 @@ const InstalledBanner = ({
export const CliStatusBanner = (): React.JSX.Element | null => {
const isElectron = useMemo(() => isElectronMode(), []);
const appConfig = useStore((s) => s.appConfig);
const updateConfig = useStore((s) => s.updateConfig);
const {
cliStatus,
cliStatusLoading,
@ -281,8 +602,14 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
} = useCliInstaller();
const [showLoginTerminal, setShowLoginTerminal] = useState(false);
const [providerTerminal, setProviderTerminal] = useState<{
providerId: CliProviderId;
action: 'login' | 'logout';
} | null>(null);
const [isVerifyingAuth, setIsVerifyingAuth] = useState(false);
const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false);
const [showTroubleshoot, setShowTroubleshoot] = useState(false);
const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true;
useEffect(() => {
if (!isElectron) return;
@ -312,6 +639,54 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
void fetchCliStatus();
}, [fetchCliStatus]);
const handleMultimodelToggle = useCallback(
async (enabled: boolean) => {
setIsSwitchingFlavor(true);
try {
await updateConfig('general', { multimodelEnabled: enabled });
await invalidateCliStatus();
await fetchCliStatus();
} finally {
setIsSwitchingFlavor(false);
}
},
[fetchCliStatus, invalidateCliStatus, updateConfig]
);
const recheckAuthState = useCallback(() => {
setIsVerifyingAuth(true);
void (async () => {
try {
await invalidateCliStatus();
await fetchCliStatus();
} finally {
setIsVerifyingAuth(false);
}
})();
}, [fetchCliStatus, invalidateCliStatus]);
const handleProviderLogin = useCallback((providerId: CliProviderId) => {
setProviderTerminal({ providerId, action: 'login' });
}, []);
const handleProviderLogout = useCallback((providerId: CliProviderId) => {
void (async () => {
const confirmed = await confirm({
title: `Logout from ${getProviderLabel(providerId)}?`,
message: 'This will remove the current provider session from the local Claude CLI runtime.',
confirmLabel: 'Logout',
cancelLabel: 'Cancel',
variant: 'danger',
});
if (!confirmed) {
return;
}
setProviderTerminal({ providerId, action: 'logout' });
})();
}, []);
if (!isElectron) return null;
// Determine variant for styling
@ -328,6 +703,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
const variant = getVariant();
const styles = VARIANT_STYLES[variant];
const providerTerminalCommand = providerTerminal
? providerTerminal.action === 'login'
? getProviderTerminalCommand(providerTerminal.providerId)
: getProviderTerminalLogoutCommand(providerTerminal.providerId)
: null;
// ── Loading / fetch error state ────────────────────────────────────────
if (!cliStatus && installerState === 'idle') {
@ -384,7 +764,27 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
);
}
// Loading state: show spinner only while an actual request is in-flight.
// Multimodel: render provider cards immediately instead of a generic intermediate block.
if (multimodelEnabled) {
return (
<InstalledBanner
cliStatus={createLoadingMultimodelStatus()}
cliStatusLoading={cliStatusLoading}
cliStatusError={cliStatusError ?? null}
isBusy={isBusy}
multimodelEnabled={multimodelEnabled}
multimodelBusy={isSwitchingFlavor}
onInstall={handleInstall}
onRefresh={handleRefresh}
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
onProviderLogin={handleProviderLogin}
onProviderLogout={handleProviderLogout}
variant="info"
/>
);
}
// Claude-only mode: keep the generic loading spinner.
return <CliCheckingSpinner styles={styles} />;
}
@ -516,22 +916,28 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
</p>
</div>
</div>
<button
onClick={handleInstall}
disabled={isBusy}
className="flex shrink-0 items-center gap-1.5 rounded-md px-4 py-2 text-sm font-medium text-white transition-colors disabled:opacity-50"
style={{ backgroundColor: '#3b82f6' }}
>
<Download className="size-4" />
Install Claude CLI
</button>
{cliStatus.supportsSelfUpdate ? (
<button
onClick={handleInstall}
disabled={isBusy}
className="flex shrink-0 items-center gap-1.5 rounded-md px-4 py-2 text-sm font-medium text-white transition-colors disabled:opacity-50"
style={{ backgroundColor: '#3b82f6' }}
>
<Download className="size-4" />
Install Claude CLI
</button>
) : (
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
The configured free-code runtime was not found.
</p>
)}
</div>
</div>
);
}
// Installed but not logged in — yellow warning banner
if (cliStatus.installed && !cliStatus.authLoggedIn) {
if (cliStatus.installed && cliStatus.flavor !== 'free-code' && !cliStatus.authLoggedIn) {
if (isVerifyingAuth) {
return (
<div
@ -565,8 +971,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
Not logged in
</p>
<p className="mt-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
Claude CLI is installed but you are not authenticated. Login is required for team
provisioning and AI features.
{cliStatus.displayName} is installed but you are not authenticated. Login is
required for team provisioning and AI features.
</p>
</div>
</div>
@ -642,28 +1048,30 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
<li>
Open your terminal and run:{' '}
<code className="rounded bg-white/5 px-1.5 py-0.5 font-mono text-[11px]">
claude auth status
{cliStatus.showBinaryPath && cliStatus.binaryPath
? `"${cliStatus.binaryPath}" auth status`
: 'your configured CLI auth status command'}
</code>{' '}
check if it shows &quot;Logged in&quot;
</li>
<li>
If it says logged in but the app doesn&apos;t see it, try:{' '}
<code className="rounded bg-white/5 px-1.5 py-0.5 font-mono text-[11px]">
claude auth logout
{cliStatus.showBinaryPath && cliStatus.binaryPath
? `"${cliStatus.binaryPath}" auth logout`
: 'the runtime logout command'}
</code>{' '}
then{' '}
<code className="rounded bg-white/5 px-1.5 py-0.5 font-mono text-[11px]">
claude auth login
{cliStatus.showBinaryPath && cliStatus.binaryPath
? `"${cliStatus.binaryPath}" auth login`
: 'the runtime login command'}
</code>{' '}
again
</li>
<li>
Make sure{' '}
<code className="rounded bg-white/5 px-1.5 py-0.5 font-mono text-[11px]">
claude
</code>{' '}
in your terminal is the same binary the app uses
{cliStatus.binaryPath && (
Make sure the CLI in your terminal is the same runtime the app uses
{cliStatus.showBinaryPath && cliStatus.binaryPath && (
<span>
:{' '}
<code className="rounded bg-white/5 px-1.5 py-0.5 font-mono text-[11px]">
@ -682,7 +1090,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
</div>
{showLoginTerminal && cliStatus.binaryPath && (
<TerminalModal
title="Claude Auth Login"
title={`${cliStatus.displayName} Login`}
command={cliStatus.binaryPath}
args={['auth', 'login']}
onClose={() => {
@ -719,14 +1127,45 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
// Installed — show version, path, update info
return (
<InstalledBanner
cliStatus={cliStatus}
cliStatusLoading={cliStatusLoading}
cliStatusError={cliStatusError ?? null}
isBusy={isBusy}
onInstall={handleInstall}
onRefresh={handleRefresh}
variant={variant}
/>
<>
<InstalledBanner
cliStatus={cliStatus}
cliStatusLoading={cliStatusLoading}
cliStatusError={cliStatusError ?? null}
isBusy={isBusy}
multimodelEnabled={multimodelEnabled}
multimodelBusy={isSwitchingFlavor}
onInstall={handleInstall}
onRefresh={handleRefresh}
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
onProviderLogin={handleProviderLogin}
onProviderLogout={handleProviderLogout}
variant={variant}
/>
{providerTerminal && cliStatus.binaryPath && (
<TerminalModal
title={`${cliStatus.displayName} ${providerTerminal.action === 'login' ? 'Login' : 'Logout'}: ${getProviderLabel(
providerTerminal.providerId
)}`}
command={cliStatus.binaryPath}
args={providerTerminalCommand?.args}
env={providerTerminalCommand?.env}
onClose={() => {
setProviderTerminal(null);
recheckAuthState();
}}
onExit={() => {
recheckAuthState();
}}
autoCloseOnSuccessMs={3000}
successMessage={
providerTerminal.action === 'login' ? 'Authentication updated' : 'Provider logged out'
}
failureMessage={
providerTerminal.action === 'login' ? 'Authentication failed' : 'Logout failed'
}
/>
)}
</>
);
};

View file

@ -29,6 +29,7 @@ export interface SafeConfig {
showDockIcon: boolean;
theme: 'dark' | 'light' | 'system';
defaultTab: 'dashboard' | 'last-session';
multimodelEnabled: boolean;
claudeRootPath: string | null;
agentLanguage: string;
autoExpandAIGroups: boolean;
@ -169,6 +170,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
showDockIcon: displayConfig?.general?.showDockIcon ?? true,
theme: displayConfig?.general?.theme ?? 'dark',
defaultTab: displayConfig?.general?.defaultTab ?? 'dashboard',
multimodelEnabled: displayConfig?.general?.multimodelEnabled ?? true,
claudeRootPath: displayConfig?.general?.claudeRootPath ?? null,
agentLanguage: displayConfig?.general?.agentLanguage ?? 'system',
autoExpandAIGroups: displayConfig?.general?.autoExpandAIGroups ?? false,

View file

@ -314,6 +314,7 @@ export function useSettingsHandlers({
showDockIcon: true,
theme: 'dark',
defaultTab: 'dashboard',
multimodelEnabled: true,
claudeRootPath: null,
agentLanguage: 'system',
autoExpandAIGroups: false,

View file

@ -5,16 +5,22 @@
* Shows detection status, version info, download progress, and error states.
*/
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { isElectronMode } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { SettingsToggle } from '@renderer/components/settings/components';
import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
import { useStore } from '@renderer/store';
import { formatBytes } from '@renderer/utils/formatters';
import {
AlertTriangle,
CheckCircle,
Download,
Loader2,
LogIn,
LogOut,
Puzzle,
RefreshCw,
Terminal,
@ -22,8 +28,132 @@ import {
import { SettingsSectionHeader } from '../components';
import type { CliInstallationStatus, CliProviderId } from '@shared/types';
function formatModelBadgeLabel(providerId: CliProviderId, model: string): string {
if (providerId === 'anthropic') {
return model.replace(/^claude-/, '');
}
if (providerId === 'codex') {
return model.replace(/^gpt-/, '');
}
if (providerId === 'gemini') {
return model.replace(/^gemini-/, '');
}
return model;
}
function ModelBadges({
providerId,
models,
}: {
providerId: CliProviderId;
models: string[];
}): React.JSX.Element {
return (
<div className="flex flex-wrap gap-1.5">
{models.map((model) => (
<span
key={model}
className="rounded-md border px-1.5 py-px font-mono text-[10px] leading-4"
style={{
borderColor: 'var(--color-border-subtle)',
backgroundColor: 'rgba(255, 255, 255, 0.03)',
color: 'var(--color-text-secondary)',
}}
>
{formatModelBadgeLabel(providerId, model)}
</span>
))}
</div>
);
}
function getProviderLabel(providerId: CliProviderId): string {
switch (providerId) {
case 'anthropic':
return 'Anthropic';
case 'codex':
return 'Codex';
case 'gemini':
return 'Gemini';
}
}
function getProviderTerminalCommand(providerId: CliProviderId): {
args: string[];
env?: Record<string, string>;
} {
if (providerId === 'gemini') {
return {
args: ['login'],
env: { CLAUDE_CODE_USE_GEMINI: '1' },
};
}
return {
args: ['auth', 'login', '--provider', providerId],
};
}
function getProviderTerminalLogoutCommand(providerId: CliProviderId): {
args: string[];
env?: Record<string, string>;
} {
if (providerId === 'gemini') {
return {
args: ['logout'],
env: { CLAUDE_CODE_USE_GEMINI: '1' },
};
}
return {
args: ['auth', 'logout', '--provider', providerId],
};
}
function createLoadingMultimodelStatus(): CliInstallationStatus {
const providers: Array<{ providerId: CliProviderId; displayName: string }> = [
{ providerId: 'anthropic', displayName: 'Anthropic' },
{ providerId: 'codex', displayName: 'Codex' },
{ providerId: 'gemini', displayName: 'Gemini' },
];
return {
flavor: 'free-code',
displayName: 'free-code-gemini-research',
supportsSelfUpdate: false,
showVersionDetails: false,
showBinaryPath: false,
installed: true,
installedVersion: null,
binaryPath: null,
latestVersion: null,
updateAvailable: false,
authLoggedIn: false,
authMethod: null,
providers: providers.map((provider) => ({
...provider,
supported: false,
authenticated: false,
authMethod: null,
verificationState: 'unknown' as const,
statusMessage: 'Checking...',
models: [],
canLoginFromUi: true,
capabilities: {
teamLaunch: false,
oneShot: false,
},
backend: null,
})),
};
}
export const CliStatusSection = (): React.JSX.Element | null => {
const isElectron = useMemo(() => isElectronMode(), []);
const appConfig = useStore((s) => s.appConfig);
const updateConfig = useStore((s) => s.updateConfig);
const {
cliStatus,
installerState,
@ -36,7 +166,18 @@ export const CliStatusSection = (): React.JSX.Element | null => {
installCli,
isBusy,
cliStatusLoading,
invalidateCliStatus,
} = useCliInstaller();
const [providerTerminal, setProviderTerminal] = useState<{
providerId: CliProviderId;
action: 'login' | 'logout';
} | null>(null);
const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false);
const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true;
const effectiveCliStatus =
!cliStatus && cliStatusLoading && multimodelEnabled
? createLoadingMultimodelStatus()
: cliStatus;
useEffect(() => {
if (isElectron) {
@ -52,38 +193,118 @@ export const CliStatusSection = (): React.JSX.Element | null => {
void fetchCliStatus();
}, [fetchCliStatus]);
const handleProviderLogout = useCallback(async (providerId: CliProviderId) => {
const confirmed = await confirm({
title: `Logout from ${getProviderLabel(providerId)}?`,
message: 'This will remove the current provider session from the local Claude CLI runtime.',
confirmLabel: 'Logout',
cancelLabel: 'Cancel',
variant: 'danger',
});
if (!confirmed) {
return;
}
setProviderTerminal({
providerId,
action: 'logout',
});
}, []);
const recheckStatus = useCallback(() => {
void (async () => {
await invalidateCliStatus();
await fetchCliStatus();
})();
}, [fetchCliStatus, invalidateCliStatus]);
const handleMultimodelToggle = useCallback(
async (enabled: boolean) => {
setIsSwitchingFlavor(true);
try {
await updateConfig('general', { multimodelEnabled: enabled });
await invalidateCliStatus();
await fetchCliStatus();
} finally {
setIsSwitchingFlavor(false);
}
},
[fetchCliStatus, invalidateCliStatus, updateConfig]
);
if (!isElectron) return null;
const runtimeLabel =
effectiveCliStatus?.flavor === 'free-code'
? null
: effectiveCliStatus &&
effectiveCliStatus.showVersionDetails &&
effectiveCliStatus.installedVersion
? `${effectiveCliStatus.displayName} v${effectiveCliStatus.installedVersion ?? 'unknown'}`
: (effectiveCliStatus?.displayName ?? 'Claude CLI');
const providerTerminalCommand = providerTerminal
? providerTerminal.action === 'login'
? getProviderTerminalCommand(providerTerminal.providerId)
: getProviderTerminalLogoutCommand(providerTerminal.providerId)
: null;
return (
<div className="mb-2">
<SettingsSectionHeader title="Claude CLI" />
<SettingsSectionHeader title="CLI Runtime" />
<div className="space-y-3 py-2">
{/* Loading status */}
{!cliStatus && installerState === 'idle' && (
{!effectiveCliStatus && installerState === 'idle' && (
<div
className="flex items-center gap-2 text-sm"
style={{ color: 'var(--color-text-muted)' }}
>
<Loader2 className="size-4 animate-spin" />
Checking CLI...
Checking AI Providers...
</div>
)}
{/* Status display */}
{cliStatus && installerState === 'idle' && (
{effectiveCliStatus && installerState === 'idle' && (
<div className="space-y-2">
{cliStatus.installed ? (
{effectiveCliStatus.installed ? (
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm">
<Terminal
className="size-4 shrink-0"
style={{ color: 'var(--color-text-muted)' }}
/>
<span style={{ color: 'var(--color-text)' }}>
Claude CLI v{cliStatus.installedVersion ?? 'unknown'}
</span>
{runtimeLabel && (
<span style={{ color: 'var(--color-text)' }}>{runtimeLabel}</span>
)}
<div className="flex items-center gap-2">
<span
className="text-xs font-medium"
style={{ color: 'var(--color-text-secondary)' }}
>
Multimodel
</span>
{multimodelEnabled && (
<span
className="rounded-full border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em]"
style={{
borderColor: 'rgba(245, 158, 11, 0.35)',
backgroundColor: 'rgba(245, 158, 11, 0.1)',
color: '#fbbf24',
}}
>
Beta
</span>
)}
<SettingsToggle
enabled={multimodelEnabled}
onChange={(value) => void handleMultimodelToggle(value)}
disabled={isBusy || cliStatusLoading || isSwitchingFlavor}
/>
</div>
{/* Inline action buttons */}
{cliStatus.updateAvailable ? (
{effectiveCliStatus.supportsSelfUpdate && effectiveCliStatus.updateAvailable ? (
<button
onClick={handleInstall}
disabled={isBusy}
@ -93,7 +314,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
<Download className="size-3.5" />
Update
</button>
) : (
) : effectiveCliStatus.supportsSelfUpdate ? (
<button
onClick={handleRefresh}
disabled={cliStatusLoading}
@ -115,7 +336,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
</>
)}
</button>
)}
) : null}
{/* Extensions button — right-aligned */}
<button
type="button"
@ -130,20 +351,116 @@ export const CliStatusSection = (): React.JSX.Element | null => {
Extensions
</button>
</div>
{cliStatus.binaryPath && (
{effectiveCliStatus.showBinaryPath && effectiveCliStatus.binaryPath && (
<p
className="ml-6 truncate text-xs"
style={{ color: 'var(--color-text-muted)' }}
title={cliStatus.binaryPath}
title={effectiveCliStatus.binaryPath}
>
{cliStatus.binaryPath}
{effectiveCliStatus.binaryPath}
</p>
)}
{cliStatus.updateAvailable && cliStatus.latestVersion && (
<div className="ml-6 flex items-center gap-2">
<span className="text-xs" style={{ color: '#60a5fa' }}>
v{cliStatus.installedVersion} &rarr; v{cliStatus.latestVersion}
</span>
{effectiveCliStatus.supportsSelfUpdate &&
effectiveCliStatus.updateAvailable &&
effectiveCliStatus.latestVersion && (
<div className="ml-6 flex items-center gap-2">
<span className="text-xs" style={{ color: '#60a5fa' }}>
v{effectiveCliStatus.installedVersion} &rarr; v
{effectiveCliStatus.latestVersion}
</span>
</div>
)}
{effectiveCliStatus.providers.length > 0 && (
<div className="ml-6 mt-3 space-y-2">
{effectiveCliStatus.providers.map((provider) => (
<div
key={provider.providerId}
className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-2 rounded-md border px-3 py-2"
style={{
borderColor: 'var(--color-border-subtle)',
backgroundColor: 'rgba(255, 255, 255, 0.02)',
}}
>
<div className="min-w-0">
<div className="flex items-center gap-2 text-xs">
<span
className="font-medium"
style={{ color: 'var(--color-text-secondary)' }}
>
{provider.displayName}
</span>
<span
style={{
color: provider.authenticated
? '#4ade80'
: 'var(--color-text-muted)',
}}
>
{provider.authenticated
? provider.authMethod
? `Authenticated via ${provider.authMethod}`
: 'Authenticated'
: provider.statusMessage || 'Not connected'}
</span>
</div>
<div
className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[11px]"
style={{ color: 'var(--color-text-muted)' }}
>
{provider.backend?.label && (
<span>Backend: {provider.backend.label}</span>
)}
{provider.models.length === 0 && (
<span>Models unavailable for this runtime build</span>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{provider.authenticated ? (
<button
type="button"
onClick={() => void handleProviderLogout(provider.providerId)}
disabled={!effectiveCliStatus.binaryPath}
className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
style={{
borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)',
}}
>
<LogOut className="size-3" />
Logout
</button>
) : provider.canLoginFromUi ? (
<button
type="button"
onClick={() =>
setProviderTerminal({
providerId: provider.providerId,
action: 'login',
})
}
disabled={!effectiveCliStatus.binaryPath || !provider.canLoginFromUi}
className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
style={{
borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)',
}}
>
<LogIn className="size-3" />
Login
</button>
) : null}
</div>
{provider.models.length > 0 && (
<div className="col-span-2">
<ModelBadges
providerId={provider.providerId}
models={provider.models}
/>
</div>
)}
</div>
))}
</div>
)}
</div>
@ -158,7 +475,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
)}
{/* Install button (CLI not installed) */}
{!cliStatus.installed && (
{!effectiveCliStatus.installed && effectiveCliStatus.supportsSelfUpdate && (
<button
onClick={handleInstall}
disabled={isBusy}
@ -169,6 +486,11 @@ export const CliStatusSection = (): React.JSX.Element | null => {
Install Claude CLI
</button>
)}
{!effectiveCliStatus.installed && !effectiveCliStatus.supportsSelfUpdate && (
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
The configured free-code runtime was not found.
</p>
)}
</div>
)}
@ -270,6 +592,30 @@ export const CliStatusSection = (): React.JSX.Element | null => {
</div>
)}
</div>
{providerTerminal && cliStatus?.binaryPath && (
<TerminalModal
title={`${cliStatus.displayName} ${providerTerminal.action === 'login' ? 'Login' : 'Logout'}: ${getProviderLabel(
providerTerminal.providerId
)}`}
command={cliStatus.binaryPath}
args={providerTerminalCommand?.args}
env={providerTerminalCommand?.env}
onClose={() => {
setProviderTerminal(null);
recheckStatus();
}}
onExit={() => {
recheckStatus();
}}
autoCloseOnSuccessMs={3000}
successMessage={
providerTerminal.action === 'login' ? 'Authentication updated' : 'Provider logged out'
}
failureMessage={
providerTerminal.action === 'login' ? 'Authentication failed' : 'Logout failed'
}
/>
)}
</div>
);
};

View file

@ -16,6 +16,8 @@ interface EmbeddedTerminalProps {
args?: string[];
/** Working directory */
cwd?: string;
/** Environment variables merged into the PTY process env */
env?: Record<string, string>;
/** Callback when PTY process exits */
onExit?: (exitCode: number) => void;
/** CSS class for container */
@ -26,6 +28,7 @@ export const EmbeddedTerminal = ({
command,
args,
cwd,
env,
onExit,
className,
}: EmbeddedTerminalProps): React.JSX.Element => {
@ -99,6 +102,7 @@ export const EmbeddedTerminal = ({
...(command ? { command } : {}),
...(args ? { args } : {}),
...(cwd ? { cwd } : {}),
...(env ? { env } : {}),
cols: term.cols,
rows: term.rows,
};

View file

@ -14,6 +14,8 @@ interface TerminalModalProps {
args?: string[];
/** Working directory */
cwd?: string;
/** Environment variables merged into the PTY process env */
env?: Record<string, string>;
/** Called when the modal should close */
onClose: () => void;
/** Called when the PTY process exits */
@ -31,6 +33,7 @@ export function TerminalModal({
command,
args,
cwd,
env,
onClose,
onExit,
autoCloseOnSuccessMs = 0,
@ -116,7 +119,7 @@ export function TerminalModal({
{/* Terminal area — always visible, status bar overlaid at bottom */}
<div className="relative flex min-h-0 flex-1 flex-col p-2">
<EmbeddedTerminal command={command} args={args} cwd={cwd} onExit={handleExit} />
<EmbeddedTerminal command={command} args={args} cwd={cwd} env={env} onExit={handleExit} />
{exited !== null && (
<div

View file

@ -1167,6 +1167,11 @@ export function initializeNotificationListeners(): () => void {
cliInstallerError: progress.error ?? 'Unknown error',
});
break;
case 'status':
if (progress.status) {
useStore.setState({ cliStatus: progress.status });
}
break;
}
});
if (typeof cleanup === 'function') {

View file

@ -21,6 +21,40 @@ export type CliPlatform =
| 'win32-x64'
| 'win32-arm64';
export type CliFlavor = 'claude' | 'free-code';
export type CliProviderId = 'anthropic' | 'codex' | 'gemini';
export interface CliProviderStatus {
providerId: CliProviderId;
displayName: string;
supported: boolean;
authenticated: boolean;
authMethod: string | null;
verificationState: 'verified' | 'unknown' | 'offline' | 'error';
statusMessage?: string | null;
models: string[];
canLoginFromUi: boolean;
capabilities: {
teamLaunch: boolean;
oneShot: boolean;
};
backend?: {
kind: string;
label: string;
endpointLabel?: string | null;
projectId?: string | null;
authMethodDetail?: string | null;
} | null;
}
export interface CliFlavorUiOptions {
displayName: string;
supportsSelfUpdate: boolean;
showVersionDetails: boolean;
showBinaryPath: boolean;
}
// =============================================================================
// Installation Status
// =============================================================================
@ -29,6 +63,16 @@ export type CliPlatform =
* Current CLI installation status returned by getStatus().
*/
export interface CliInstallationStatus {
/** Selected CLI runtime flavor */
flavor: CliFlavor;
/** Display label for the configured runtime */
displayName: string;
/** Whether this runtime should expose self-update/install actions in the UI */
supportsSelfUpdate: boolean;
/** Whether version text should be shown in the UI */
showVersionDetails: boolean;
/** Whether binary path should be shown in the UI */
showBinaryPath: boolean;
/** Whether CLI binary is found on the system */
installed: boolean;
/** Installed version string (e.g. "2.1.59"), null if not installed */
@ -43,6 +87,8 @@ export interface CliInstallationStatus {
authLoggedIn: boolean;
/** Auth method if logged in (e.g. "oauth_token", "api_key"), null otherwise */
authMethod: string | null;
/** Provider-level runtime status when supported by the configured runtime */
providers: CliProviderStatus[];
}
// =============================================================================
@ -54,7 +100,7 @@ export interface CliInstallationStatus {
*/
export interface CliInstallerProgress {
/** Current phase of the installation process */
type: 'checking' | 'downloading' | 'verifying' | 'installing' | 'completed' | 'error';
type: 'checking' | 'downloading' | 'verifying' | 'installing' | 'completed' | 'error' | 'status';
/** Download progress 0-100, only present for 'downloading' */
percent?: number;
/** Bytes downloaded so far */
@ -69,6 +115,8 @@ export interface CliInstallerProgress {
detail?: string;
/** Raw terminal output chunk (with ANSI codes), only for 'installing' */
rawChunk?: string;
/** Partial or full CLI status snapshot during status gathering. */
status?: CliInstallationStatus;
}
// =============================================================================

View file

@ -308,6 +308,8 @@ export interface AppConfig {
theme: 'dark' | 'light' | 'system';
/** Default tab to show on app launch */
defaultTab: 'dashboard' | 'last-session';
/** Whether to use the multimodel runtime instead of the stock Claude CLI */
multimodelEnabled: boolean;
/** Optional custom Claude root folder (auto-detected when null) */
claudeRootPath: string | null;
/** Agent communication language ('system' = use OS locale) */

View file

@ -0,0 +1,160 @@
// @vitest-environment node
import type { PathLike } from 'fs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const execCliMock = vi.fn();
const buildEnrichedEnvMock = vi.fn<(binaryPath: string) => NodeJS.ProcessEnv>();
const getCachedShellEnvMock = vi.fn<() => NodeJS.ProcessEnv | null>();
const getShellPreferredHomeMock = vi.fn<() => string>();
const resolveInteractiveShellEnvMock = vi.fn<() => Promise<NodeJS.ProcessEnv>>();
const readFileMock = vi.fn<(path: PathLike, encoding: BufferEncoding) => Promise<string>>();
vi.mock('@main/utils/childProcess', () => ({
execCli: (...args: Parameters<typeof execCliMock>) => execCliMock(...args),
}));
vi.mock('@main/utils/cliEnv', () => ({
buildEnrichedEnv: (binaryPath: string) => buildEnrichedEnvMock(binaryPath),
}));
vi.mock('@main/utils/shellEnv', () => ({
getCachedShellEnv: () => getCachedShellEnvMock(),
getShellPreferredHome: () => getShellPreferredHomeMock(),
resolveInteractiveShellEnv: () => resolveInteractiveShellEnvMock(),
}));
vi.mock('fs', () => ({
default: {
promises: {
readFile: (filePath: PathLike, encoding: BufferEncoding) => readFileMock(filePath, encoding),
},
},
promises: {
readFile: (filePath: PathLike, encoding: BufferEncoding) => readFileMock(filePath, encoding),
},
}));
describe('ClaudeMultimodelBridgeService', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
buildEnrichedEnvMock.mockReturnValue({});
getCachedShellEnvMock.mockReturnValue({});
getShellPreferredHomeMock.mockReturnValue('/Users/tester');
resolveInteractiveShellEnvMock.mockResolvedValue({});
readFileMock.mockImplementation(async (filePath) => {
if (String(filePath) === '/Users/tester/.claude.json') {
return JSON.stringify({
geminiResolvedBackend: 'cli',
geminiLastAuthMethod: 'cli_oauth_personal',
geminiProjectId: 'demo-project',
});
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
});
it('parses object-based model lists and exposes Gemini runtime status', async () => {
execCliMock.mockImplementation(async (_binaryPath, args, options) => {
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
const env = options?.env ?? {};
if (normalizedArgs === 'auth status --json --provider all') {
return {
stdout: JSON.stringify({
providers: {
anthropic: {
supported: true,
authenticated: true,
authMethod: 'oauth_token',
verificationState: 'verified',
canLoginFromUi: true,
capabilities: { teamLaunch: true, oneShot: true },
backend: { kind: 'anthropic', label: 'Anthropic' },
},
codex: {
supported: true,
authenticated: false,
verificationState: 'verified',
canLoginFromUi: true,
statusMessage: 'Not connected',
capabilities: { teamLaunch: true, oneShot: true },
backend: { kind: 'openai', label: 'OpenAI' },
},
},
}),
stderr: '',
exitCode: 0,
};
}
if (normalizedArgs === 'model list --json --provider all' && env.CLAUDE_CODE_USE_GEMINI === '1') {
return {
stdout: JSON.stringify({
providers: {
gemini: {
models: [{ id: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' }],
},
},
}),
stderr: '',
exitCode: 0,
};
}
if (normalizedArgs === 'model list --json --provider all') {
return {
stdout: JSON.stringify({
providers: {
anthropic: {
models: [{ id: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5' }],
},
codex: {
models: [{ id: 'gpt-5-codex', label: 'GPT-5 Codex' }],
},
},
}),
stderr: '',
exitCode: 0,
};
}
throw new Error(`Unexpected execCli call: ${normalizedArgs}`);
});
const { ClaudeMultimodelBridgeService } = await import(
'@main/services/runtime/ClaudeMultimodelBridgeService'
);
const service = new ClaudeMultimodelBridgeService();
const providers = await service.getProviderStatuses('/mock/free-code');
expect(providers).toHaveLength(3);
expect(providers[0]).toMatchObject({
providerId: 'anthropic',
authenticated: true,
models: ['claude-sonnet-4-5'],
});
expect(providers[1]).toMatchObject({
providerId: 'codex',
authenticated: false,
models: ['gpt-5-codex'],
statusMessage: 'Not connected',
});
expect(providers[2]).toMatchObject({
providerId: 'gemini',
displayName: 'Gemini',
supported: true,
authenticated: true,
models: ['gemini-2.5-pro'],
canLoginFromUi: true,
authMethod: 'cli_oauth_personal',
backend: {
kind: 'cli',
label: 'Gemini CLI',
endpointLabel: 'Code Assist (cloudcode-pa.googleapis.com/v1internal)',
projectId: 'demo-project',
},
});
});
});

View file

@ -0,0 +1,29 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import {
applyProviderRuntimeEnv,
resolveTeamProviderId,
} from '@main/services/runtime/providerRuntimeEnv';
describe('providerRuntimeEnv', () => {
it('enables Gemini runtime mode and clears other third-party provider flags', () => {
const env: NodeJS.ProcessEnv = {
CLAUDE_CODE_USE_OPENAI: '1',
CLAUDE_CODE_USE_GEMINI: undefined,
CLAUDE_CODE_USE_BEDROCK: '1',
};
const result = applyProviderRuntimeEnv(env, 'gemini');
expect(result.CLAUDE_CODE_USE_GEMINI).toBe('1');
expect(result.CLAUDE_CODE_USE_OPENAI).toBeUndefined();
expect(result.CLAUDE_CODE_USE_BEDROCK).toBeUndefined();
});
it('preserves gemini as a valid team provider id', () => {
expect(resolveTeamProviderId('gemini')).toBe('gemini');
expect(resolveTeamProviderId('codex')).toBe('codex');
expect(resolveTeamProviderId(undefined)).toBe('anthropic');
});
});

View file

@ -0,0 +1,94 @@
// @vitest-environment node
import type { PathLike } from 'fs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mockBuildMergedCliPath = vi.fn<(binaryPath: string | null) => string>();
const mockGetShellPreferredHome = vi.fn<() => string>();
const mockResolveInteractiveShellEnv = vi.fn<() => Promise<NodeJS.ProcessEnv>>();
const mockGetConfiguredCliFlavor = vi.fn<() => 'claude' | 'free-code'>();
const accessMock = vi.fn<(filePath: PathLike, mode?: number) => Promise<void>>();
const statMock = vi.fn<
(filePath: PathLike) => Promise<{ isFile: () => boolean }>
>();
const readdirMock = vi.fn<(filePath: PathLike) => Promise<string[]>>();
vi.mock('@main/utils/cliPathMerge', () => ({
buildMergedCliPath: (binaryPath: string | null) => mockBuildMergedCliPath(binaryPath),
}));
vi.mock('@main/utils/shellEnv', () => ({
getShellPreferredHome: () => mockGetShellPreferredHome(),
resolveInteractiveShellEnv: () => mockResolveInteractiveShellEnv(),
}));
vi.mock('@main/services/team/cliFlavor', () => ({
getConfiguredCliFlavor: () => mockGetConfiguredCliFlavor(),
}));
vi.mock('fs', () => ({
default: {
constants: { X_OK: 1 },
promises: {
access: (filePath: PathLike, mode?: number) => accessMock(filePath, mode),
stat: (filePath: PathLike) => statMock(filePath),
readdir: (filePath: PathLike) => readdirMock(filePath),
},
},
constants: { X_OK: 1 },
promises: {
access: (filePath: PathLike, mode?: number) => accessMock(filePath, mode),
stat: (filePath: PathLike) => statMock(filePath),
readdir: (filePath: PathLike) => readdirMock(filePath),
},
}));
describe('ClaudeBinaryResolver', () => {
const originalPlatform = process.platform;
const originalCwd = process.cwd;
const workspaceRoot = '/Users/belief/dev/projects/claude/claude_team_freecode';
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
mockBuildMergedCliPath.mockReturnValue('/usr/local/bin:/usr/bin');
mockGetShellPreferredHome.mockReturnValue('/Users/tester');
mockResolveInteractiveShellEnv.mockResolvedValue({});
mockGetConfiguredCliFlavor.mockReturnValue('free-code');
readdirMock.mockResolvedValue([]);
Object.defineProperty(process, 'platform', {
value: 'darwin',
configurable: true,
writable: true,
});
process.cwd = vi.fn(() => workspaceRoot);
delete process.env.CLAUDE_CLI_PATH;
});
afterEach(() => {
Object.defineProperty(process, 'platform', {
value: originalPlatform,
configurable: true,
writable: true,
});
process.cwd = originalCwd;
vi.unstubAllEnvs();
});
it('resolves free-code runtime from the free-code-gemini-research sibling repo', async () => {
const expectedBinary = '/Users/belief/dev/projects/claude/free-code-gemini-research/cli';
accessMock.mockImplementation(async (filePath) => {
if (filePath === expectedBinary) {
return;
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
const { ClaudeBinaryResolver } = await import('@main/services/team/ClaudeBinaryResolver');
ClaudeBinaryResolver.clearCache();
await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary);
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
});
});

View file

@ -0,0 +1,55 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from 'vitest';
const getConfigMock = vi.fn();
vi.mock('@main/services/infrastructure/ConfigManager', () => ({
configManager: {
getConfig: () => getConfigMock(),
},
}));
describe('cliFlavor', () => {
afterEach(() => {
delete process.env.CLAUDE_TEAM_CLI_FLAVOR;
vi.resetModules();
vi.clearAllMocks();
});
it('uses multimodel runtime by default when config enables it', async () => {
getConfigMock.mockReturnValue({
general: {
multimodelEnabled: true,
},
});
const { getConfiguredCliFlavor } = await import('@main/services/team/cliFlavor');
expect(getConfiguredCliFlavor()).toBe('free-code');
});
it('uses claude runtime when multimodel is disabled in config', async () => {
getConfigMock.mockReturnValue({
general: {
multimodelEnabled: false,
},
});
const { getConfiguredCliFlavor } = await import('@main/services/team/cliFlavor');
expect(getConfiguredCliFlavor()).toBe('claude');
});
it('lets env override the persisted config', async () => {
process.env.CLAUDE_TEAM_CLI_FLAVOR = 'claude';
getConfigMock.mockReturnValue({
general: {
multimodelEnabled: true,
},
});
const { getConfiguredCliFlavor } = await import('@main/services/team/cliFlavor');
expect(getConfiguredCliFlavor()).toBe('claude');
});
});

View file

@ -83,6 +83,11 @@ describe('cliInstallerSlice', () => {
describe('fetchCliStatus', () => {
it('updates cliStatus from API', async () => {
const mockStatus: CliInstallationStatus = {
flavor: 'claude',
displayName: 'Claude CLI',
supportsSelfUpdate: true,
showVersionDetails: true,
showBinaryPath: true,
installed: true,
installedVersion: '2.1.59',
binaryPath: '/usr/local/bin/claude',
@ -90,6 +95,7 @@ describe('cliInstallerSlice', () => {
updateAvailable: false,
authLoggedIn: false,
authMethod: null,
providers: [],
};
vi.mocked(api.cliInstaller.getStatus).mockResolvedValue(mockStatus);
@ -109,6 +115,11 @@ describe('cliInstallerSlice', () => {
it('detects update available', async () => {
const mockStatus: CliInstallationStatus = {
flavor: 'claude',
displayName: 'Claude CLI',
supportsSelfUpdate: true,
showVersionDetails: true,
showBinaryPath: true,
installed: true,
installedVersion: '2.1.34',
binaryPath: '/usr/local/bin/claude',
@ -116,6 +127,7 @@ describe('cliInstallerSlice', () => {
updateAvailable: true,
authLoggedIn: true,
authMethod: 'oauth_token',
providers: [],
};
vi.mocked(api.cliInstaller.getStatus).mockResolvedValue(mockStatus);