From bae3609561204fb73b0296030729a806b093dc53 Mon Sep 17 00:00:00 2001
From: iliya
Date: Thu, 2 Apr 2026 10:22:11 +0300
Subject: [PATCH] feat(multimodel): add free-code runtime and provider status
UI
---
docs/claude-multimodel-integration-plan.md | 825 ++++++++++++++++++
pnpm-lock.yaml | 17 +-
src/main/ipc/cliInstaller.ts | 2 +
src/main/ipc/configValidation.ts | 7 +
.../infrastructure/CliInstallerService.ts | 116 ++-
.../services/infrastructure/ConfigManager.ts | 2 +
.../runtime/ClaudeMultimodelBridgeService.ts | 299 +++++++
.../services/runtime/geminiRuntimeAuth.ts | 113 +++
.../services/runtime/providerRuntimeEnv.ts | 33 +
.../schedule/ScheduledTaskExecutor.ts | 8 +-
.../services/team/ClaudeBinaryResolver.ts | 38 +
src/main/services/team/cliFlavor.ts | 43 +
src/preload/index.ts | 13 +-
.../components/dashboard/CliStatusBanner.tsx | 545 ++++++++++--
.../settings/hooks/useSettingsConfig.ts | 2 +
.../settings/hooks/useSettingsHandlers.ts | 1 +
.../settings/sections/CliStatusSection.tsx | 388 +++++++-
.../components/terminal/EmbeddedTerminal.tsx | 4 +
.../components/terminal/TerminalModal.tsx | 5 +-
src/renderer/store/index.ts | 5 +
src/shared/types/cliInstaller.ts | 50 +-
src/shared/types/notifications.ts | 2 +
.../ClaudeMultimodelBridgeService.test.ts | 160 ++++
.../runtime/providerRuntimeEnv.test.ts | 29 +
.../team/ClaudeBinaryResolver.test.ts | 94 ++
test/main/services/team/cliFlavor.test.ts | 55 ++
test/renderer/store/cliInstallerSlice.test.ts | 12 +
27 files changed, 2780 insertions(+), 88 deletions(-)
create mode 100644 docs/claude-multimodel-integration-plan.md
create mode 100644 src/main/services/runtime/ClaudeMultimodelBridgeService.ts
create mode 100644 src/main/services/runtime/geminiRuntimeAuth.ts
create mode 100644 src/main/services/runtime/providerRuntimeEnv.ts
create mode 100644 src/main/services/team/cliFlavor.ts
create mode 100644 test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts
create mode 100644 test/main/services/runtime/providerRuntimeEnv.test.ts
create mode 100644 test/main/services/team/ClaudeBinaryResolver.test.ts
create mode 100644 test/main/services/team/cliFlavor.test.ts
diff --git a/docs/claude-multimodel-integration-plan.md b/docs/claude-multimodel-integration-plan.md
new file mode 100644
index 00000000..e4d983bd
--- /dev/null
+++ b/docs/claude-multimodel-integration-plan.md
@@ -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;
+ memberModels?: Record;
+}
+```
+
+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`
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f81f1a5b..972d9968 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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
diff --git a/src/main/ipc/cliInstaller.ts b/src/main/ipc/cliInstaller.ts
index de4d5fda..8511140d 100644
--- a/src/main/ipc/cliInstaller.ts
+++ b/src/main/ipc/cliInstaller.ts
@@ -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 {
cachedStatus = null;
+ ClaudeBinaryResolver.clearCache();
return { success: true, data: undefined };
}
diff --git a/src/main/ipc/configValidation.ts b/src/main/ipc/configValidation.ts
index 9d322564..d9a5104e 100644
--- a/src/main/ipc/configValidation.ts
+++ b/src/main/ipc/configValidation.ts
@@ -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;
diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts
index 4e4b0d8f..70c9cfe2 100644
--- a/src/main/services/infrastructure/CliInstallerService.ts
+++ b/src/main/services/infrastructure/CliInstallerService.ts
@@ -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 {
try {
@@ -370,12 +385,48 @@ export class CliInstallerService {
return buildEnrichedEnv(binaryPath);
}
- // ---------------------------------------------------------------------------
- // Public: getStatus
- // ---------------------------------------------------------------------------
-
- async getStatus(): Promise {
- 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 {
+ 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 {
+ 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 => {
for (let authAttempt = 1; authAttempt <= AUTH_STATUS_MAX_RETRIES; authAttempt++) {
diag.authAttempts = authAttempt;
@@ -559,6 +652,13 @@ export class CliInstallerService {
// ---------------------------------------------------------------------------
async install(): Promise {
+ 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;
diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts
index a1d09b79..f6aa1d3f 100644
--- a/src/main/services/infrastructure/ConfigManager.ts
+++ b/src/main/services/infrastructure/ConfigManager.ts
@@ -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,
diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts
new file mode 100644
index 00000000..5dc5638e
--- /dev/null
+++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts
@@ -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;
+ }
+ >;
+}
+
+const ORDERED_PROVIDER_IDS: CliProviderId[] = ['anthropic', 'codex', 'gemini'];
+
+function extractJsonObject(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 | 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 {
+ 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(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 {
+ 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(
+ ORDERED_PROVIDER_IDS.map((providerId) => [
+ providerId,
+ createDefaultProviderStatus(providerId),
+ ])
+ );
+
+ if (statusResult.status === 'fulfilled') {
+ try {
+ const parsed = extractJsonObject(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(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)!);
+ }
+}
diff --git a/src/main/services/runtime/geminiRuntimeAuth.ts b/src/main/services/runtime/geminiRuntimeAuth.ts
new file mode 100644
index 00000000..0eb81a47
--- /dev/null
+++ b/src/main/services/runtime/geminiRuntimeAuth.ts
@@ -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 {
+ 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 {
+ 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.',
+ };
+}
diff --git a/src/main/services/runtime/providerRuntimeEnv.ts b/src/main/services/runtime/providerRuntimeEnv.ts
new file mode 100644
index 00000000..2739c2d6
--- /dev/null
+++ b/src/main/services/runtime/providerRuntimeEnv.ts
@@ -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';
+}
diff --git a/src/main/services/schedule/ScheduledTaskExecutor.ts b/src/main/services/schedule/ScheduledTaskExecutor.ts
index a16cb57a..00c04d39 100644
--- a/src/main/services/schedule/ScheduledTaskExecutor.ts
+++ b/src/main/services/schedule/ScheduledTaskExecutor.ts
@@ -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'],
});
diff --git a/src/main/services/team/ClaudeBinaryResolver.ts b/src/main/services/team/ClaudeBinaryResolver.ts
index 644354cf..13a75782 100644
--- a/src/main/services/team/ClaudeBinaryResolver.ts
+++ b/src/main/services/team/ClaudeBinaryResolver.ts
@@ -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 {
if (process.platform === 'win32') {
try {
@@ -165,6 +167,28 @@ async function resolveFromExplicitPath(inputPath: string): Promise {
+ 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 {
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) {
diff --git a/src/main/services/team/cliFlavor.ts b/src/main/services/team/cliFlavor.ts
new file mode 100644
index 00000000..98d47306
--- /dev/null
+++ b/src/main/services/team/cliFlavor.ts
@@ -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,
+ };
+ }
+}
diff --git a/src/preload/index.ts b/src/preload/index.ts
index f5ab238a..4918726a 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -837,8 +837,17 @@ const electronAPI: ElectronAPI = {
deleteDraft: async (teamName: string) => {
return invokeIpcWithResult(TEAM_DELETE_DRAFT, teamName);
},
- prepareProvisioning: async (cwd?: string) => {
- return invokeIpcWithResult(TEAM_PREPARE_PROVISIONING, cwd);
+ prepareProvisioning: async (
+ cwd?: string,
+ providerId?: TeamLaunchRequest['providerId'],
+ providerIds?: TeamLaunchRequest['providerId'][]
+ ) => {
+ return invokeIpcWithResult(
+ TEAM_PREPARE_PROVISIONING,
+ cwd,
+ providerId,
+ providerIds
+ );
},
createTeam: async (request: TeamCreateRequest) => {
return invokeIpcWithResult(TEAM_CREATE, request);
diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx
index e19d3fe7..23ee9564 100644
--- a/src/renderer/components/dashboard/CliStatusBanner.tsx
+++ b/src/renderer/components/dashboard/CliStatusBanner.tsx
@@ -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 = ({
/>
- Checking Claude CLI...
+ Checking AI Providers...
{showHint && (
@@ -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;
+} {
+ 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;
+} {
+ 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 (
+
+ {models.map((model) => (
+
+ {formatModelBadgeLabel(providerId, model)}
+
+ ))}
+
+ );
+}
+
+function formatRuntimeLabel(
+ cliStatus: NonNullable['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['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 (
-
- Claude CLI v{cliStatus.installedVersion ?? 'unknown'}
-
+ {runtimeLabel && (
+
+ {runtimeLabel}
+
+ )}
{/* Update / Check for Updates — inline next to version */}
- {cliStatus.updateAvailable ? (
+ {cliStatus.supportsSelfUpdate && cliStatus.updateAvailable ? (
Update to v{cliStatus.latestVersion}
- ) : (
+ ) : cliStatus.supportsSelfUpdate ? (
{cliStatusLoading ? 'Checking...' : 'Check for Updates'}
- )}
+ ) : null}
- {cliStatus.authLoggedIn && (
+ {runtimeAuthSummary && (
- Authenticated
+ {runtimeAuthSummary}
)}
- {cliStatus.binaryPath && (
+ {cliStatus.showBinaryPath && cliStatus.binaryPath && (
- {/* Extensions button — only when installed + authenticated */}
- {cliStatus.authLoggedIn && (
-
-
- Extensions
-
- )}
+
+
+
+ Multimodel
+
+ {multimodelEnabled && (
+
+ Beta
+
+ )}
+
+
+ {/* Extensions button — only when installed + authenticated */}
+ {cliStatus.authLoggedIn && (
+
+
+ Extensions
+
+ )}
+
{cliStatusError && !cliStatusLoading && (
Failed to check for updates. Check your network connection and try again.
)}
+ {cliStatus.providers.length > 0 && (
+
+ {cliStatus.providers.map((provider) => {
+ const statusText = formatProviderStatus(provider);
+ const actionDisabled = isBusy || !cliStatus.binaryPath;
+
+ return (
+
+
+
+
+ {provider.displayName}
+
+
+ {statusText}
+
+
+
+ {provider.backend?.label && (
+
+ Backend: {provider.backend.label}
+ {provider.backend.endpointLabel
+ ? ` (${provider.backend.endpointLabel})`
+ : ''}
+
+ )}
+ {provider.models.length === 0 && (
+ Models unavailable for this runtime build
+ )}
+
+
+
+ {provider.authenticated ? (
+ 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
+
+ ) : provider.canLoginFromUi ? (
+ 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
+
+ ) : null}
+
+
+
+
+ {provider.models.length > 0 && (
+
+
+
+ )}
+
+ );
+ })}
+
+ )}
);
};
@@ -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 (
+ void handleMultimodelToggle(enabled)}
+ onProviderLogin={handleProviderLogin}
+ onProviderLogout={handleProviderLogout}
+ variant="info"
+ />
+ );
+ }
+
+ // Claude-only mode: keep the generic loading spinner.
return ;
}
@@ -516,22 +916,28 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
-
-
- Install Claude CLI
-
+ {cliStatus.supportsSelfUpdate ? (
+
+
+ Install Claude CLI
+
+ ) : (
+
+ The configured free-code runtime was not found.
+
+ )}
);
}
// 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 (
{
Not logged in
- 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.
@@ -642,28 +1048,30 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
Open your terminal and run:{' '}
- claude auth status
+ {cliStatus.showBinaryPath && cliStatus.binaryPath
+ ? `"${cliStatus.binaryPath}" auth status`
+ : 'your configured CLI auth status command'}
{' '}
— check if it shows "Logged in"
If it says logged in but the app doesn't see it, try:{' '}
- claude auth logout
+ {cliStatus.showBinaryPath && cliStatus.binaryPath
+ ? `"${cliStatus.binaryPath}" auth logout`
+ : 'the runtime logout command'}
{' '}
then{' '}
- claude auth login
+ {cliStatus.showBinaryPath && cliStatus.binaryPath
+ ? `"${cliStatus.binaryPath}" auth login`
+ : 'the runtime login command'}
{' '}
again
- Make sure{' '}
-
- claude
- {' '}
- 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 && (
:{' '}
@@ -682,7 +1090,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
{showLoginTerminal && cliStatus.binaryPath && (
{
@@ -719,14 +1127,45 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
// Installed — show version, path, update info
return (
-
+ <>
+ void handleMultimodelToggle(enabled)}
+ onProviderLogin={handleProviderLogin}
+ onProviderLogout={handleProviderLogout}
+ variant={variant}
+ />
+ {providerTerminal && cliStatus.binaryPath && (
+ {
+ setProviderTerminal(null);
+ recheckAuthState();
+ }}
+ onExit={() => {
+ recheckAuthState();
+ }}
+ autoCloseOnSuccessMs={3000}
+ successMessage={
+ providerTerminal.action === 'login' ? 'Authentication updated' : 'Provider logged out'
+ }
+ failureMessage={
+ providerTerminal.action === 'login' ? 'Authentication failed' : 'Logout failed'
+ }
+ />
+ )}
+ >
);
};
diff --git a/src/renderer/components/settings/hooks/useSettingsConfig.ts b/src/renderer/components/settings/hooks/useSettingsConfig.ts
index bc6b84f8..0ab735d2 100644
--- a/src/renderer/components/settings/hooks/useSettingsConfig.ts
+++ b/src/renderer/components/settings/hooks/useSettingsConfig.ts
@@ -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,
diff --git a/src/renderer/components/settings/hooks/useSettingsHandlers.ts b/src/renderer/components/settings/hooks/useSettingsHandlers.ts
index 76af24e2..18e964e5 100644
--- a/src/renderer/components/settings/hooks/useSettingsHandlers.ts
+++ b/src/renderer/components/settings/hooks/useSettingsHandlers.ts
@@ -314,6 +314,7 @@ export function useSettingsHandlers({
showDockIcon: true,
theme: 'dark',
defaultTab: 'dashboard',
+ multimodelEnabled: true,
claudeRootPath: null,
agentLanguage: 'system',
autoExpandAIGroups: false,
diff --git a/src/renderer/components/settings/sections/CliStatusSection.tsx b/src/renderer/components/settings/sections/CliStatusSection.tsx
index c351c371..8c00fabe 100644
--- a/src/renderer/components/settings/sections/CliStatusSection.tsx
+++ b/src/renderer/components/settings/sections/CliStatusSection.tsx
@@ -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 (
+
+ {models.map((model) => (
+
+ {formatModelBadgeLabel(providerId, model)}
+
+ ))}
+
+ );
+}
+
+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;
+} {
+ 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;
+} {
+ 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 (
-
+
{/* Loading status */}
- {!cliStatus && installerState === 'idle' && (
+ {!effectiveCliStatus && installerState === 'idle' && (
- Checking CLI...
+ Checking AI Providers...
)}
{/* Status display */}
- {cliStatus && installerState === 'idle' && (
+ {effectiveCliStatus && installerState === 'idle' && (
- {cliStatus.installed ? (
+ {effectiveCliStatus.installed ? (
-
- Claude CLI v{cliStatus.installedVersion ?? 'unknown'}
-
+ {runtimeLabel && (
+
{runtimeLabel}
+ )}
+
+
+ Multimodel
+
+ {multimodelEnabled && (
+
+ Beta
+
+ )}
+ void handleMultimodelToggle(value)}
+ disabled={isBusy || cliStatusLoading || isSwitchingFlavor}
+ />
+
{/* Inline action buttons */}
- {cliStatus.updateAvailable ? (
+ {effectiveCliStatus.supportsSelfUpdate && effectiveCliStatus.updateAvailable ? (
{
Update
- ) : (
+ ) : effectiveCliStatus.supportsSelfUpdate ? (
{
>
)}
- )}
+ ) : null}
{/* Extensions button — right-aligned */}
{
Extensions
- {cliStatus.binaryPath && (
+ {effectiveCliStatus.showBinaryPath && effectiveCliStatus.binaryPath && (
- {cliStatus.binaryPath}
+ {effectiveCliStatus.binaryPath}
)}
- {cliStatus.updateAvailable && cliStatus.latestVersion && (
-
-
- v{cliStatus.installedVersion} → v{cliStatus.latestVersion}
-
+ {effectiveCliStatus.supportsSelfUpdate &&
+ effectiveCliStatus.updateAvailable &&
+ effectiveCliStatus.latestVersion && (
+
+
+ v{effectiveCliStatus.installedVersion} → v
+ {effectiveCliStatus.latestVersion}
+
+
+ )}
+ {effectiveCliStatus.providers.length > 0 && (
+
+ {effectiveCliStatus.providers.map((provider) => (
+
+
+
+
+ {provider.displayName}
+
+
+ {provider.authenticated
+ ? provider.authMethod
+ ? `Authenticated via ${provider.authMethod}`
+ : 'Authenticated'
+ : provider.statusMessage || 'Not connected'}
+
+
+
+ {provider.backend?.label && (
+ Backend: {provider.backend.label}
+ )}
+ {provider.models.length === 0 && (
+ Models unavailable for this runtime build
+ )}
+
+
+
+ {provider.authenticated ? (
+ 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
+
+ ) : provider.canLoginFromUi ? (
+
+ 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
+
+ ) : null}
+
+ {provider.models.length > 0 && (
+
+
+
+ )}
+
+ ))}
)}
@@ -158,7 +475,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
)}
{/* Install button (CLI not installed) */}
- {!cliStatus.installed && (
+ {!effectiveCliStatus.installed && effectiveCliStatus.supportsSelfUpdate && (
{
Install Claude CLI
)}
+ {!effectiveCliStatus.installed && !effectiveCliStatus.supportsSelfUpdate && (
+
+ The configured free-code runtime was not found.
+
+ )}
)}
@@ -270,6 +592,30 @@ export const CliStatusSection = (): React.JSX.Element | null => {
)}
+ {providerTerminal && cliStatus?.binaryPath && (
+
{
+ setProviderTerminal(null);
+ recheckStatus();
+ }}
+ onExit={() => {
+ recheckStatus();
+ }}
+ autoCloseOnSuccessMs={3000}
+ successMessage={
+ providerTerminal.action === 'login' ? 'Authentication updated' : 'Provider logged out'
+ }
+ failureMessage={
+ providerTerminal.action === 'login' ? 'Authentication failed' : 'Logout failed'
+ }
+ />
+ )}
);
};
diff --git a/src/renderer/components/terminal/EmbeddedTerminal.tsx b/src/renderer/components/terminal/EmbeddedTerminal.tsx
index 895cf062..78acc77d 100644
--- a/src/renderer/components/terminal/EmbeddedTerminal.tsx
+++ b/src/renderer/components/terminal/EmbeddedTerminal.tsx
@@ -16,6 +16,8 @@ interface EmbeddedTerminalProps {
args?: string[];
/** Working directory */
cwd?: string;
+ /** Environment variables merged into the PTY process env */
+ env?: Record;
/** 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,
};
diff --git a/src/renderer/components/terminal/TerminalModal.tsx b/src/renderer/components/terminal/TerminalModal.tsx
index a7c7f4e7..88c8dc37 100644
--- a/src/renderer/components/terminal/TerminalModal.tsx
+++ b/src/renderer/components/terminal/TerminalModal.tsx
@@ -14,6 +14,8 @@ interface TerminalModalProps {
args?: string[];
/** Working directory */
cwd?: string;
+ /** Environment variables merged into the PTY process env */
+ env?: Record;
/** 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 */}
-
+
{exited !== null && (
void {
cliInstallerError: progress.error ?? 'Unknown error',
});
break;
+ case 'status':
+ if (progress.status) {
+ useStore.setState({ cliStatus: progress.status });
+ }
+ break;
}
});
if (typeof cleanup === 'function') {
diff --git a/src/shared/types/cliInstaller.ts b/src/shared/types/cliInstaller.ts
index 9149ead1..00433e24 100644
--- a/src/shared/types/cliInstaller.ts
+++ b/src/shared/types/cliInstaller.ts
@@ -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;
}
// =============================================================================
diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts
index cd36ca7b..f273bf23 100644
--- a/src/shared/types/notifications.ts
+++ b/src/shared/types/notifications.ts
@@ -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) */
diff --git a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts
new file mode 100644
index 00000000..6a45cd38
--- /dev/null
+++ b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts
@@ -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>();
+const readFileMock = vi.fn<(path: PathLike, encoding: BufferEncoding) => Promise>();
+
+vi.mock('@main/utils/childProcess', () => ({
+ execCli: (...args: Parameters) => 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',
+ },
+ });
+ });
+});
diff --git a/test/main/services/runtime/providerRuntimeEnv.test.ts b/test/main/services/runtime/providerRuntimeEnv.test.ts
new file mode 100644
index 00000000..e7f4d92a
--- /dev/null
+++ b/test/main/services/runtime/providerRuntimeEnv.test.ts
@@ -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');
+ });
+});
diff --git a/test/main/services/team/ClaudeBinaryResolver.test.ts b/test/main/services/team/ClaudeBinaryResolver.test.ts
new file mode 100644
index 00000000..b65a6098
--- /dev/null
+++ b/test/main/services/team/ClaudeBinaryResolver.test.ts
@@ -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>();
+const mockGetConfiguredCliFlavor = vi.fn<() => 'claude' | 'free-code'>();
+
+const accessMock = vi.fn<(filePath: PathLike, mode?: number) => Promise>();
+const statMock = vi.fn<
+ (filePath: PathLike) => Promise<{ isFile: () => boolean }>
+>();
+const readdirMock = vi.fn<(filePath: PathLike) => Promise>();
+
+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);
+ });
+});
diff --git a/test/main/services/team/cliFlavor.test.ts b/test/main/services/team/cliFlavor.test.ts
new file mode 100644
index 00000000..a4612c84
--- /dev/null
+++ b/test/main/services/team/cliFlavor.test.ts
@@ -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');
+ });
+});
diff --git a/test/renderer/store/cliInstallerSlice.test.ts b/test/renderer/store/cliInstallerSlice.test.ts
index d9dd9e92..7177eee6 100644
--- a/test/renderer/store/cliInstallerSlice.test.ts
+++ b/test/renderer/store/cliInstallerSlice.test.ts
@@ -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);