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 ? ( - ) : ( + ) : cliStatus.supportsSelfUpdate ? ( - )} + ) : null} - {cliStatus.authLoggedIn && ( + {runtimeAuthSummary && ( - Authenticated + {runtimeAuthSummary} )}
- {cliStatus.binaryPath && ( + {cliStatus.showBinaryPath && cliStatus.binaryPath && (
- {/* Extensions button — only when installed + authenticated */} - {cliStatus.authLoggedIn && ( - - )} +
+
+ + Multimodel + + {multimodelEnabled && ( + + Beta + + )} + +
+ {/* Extensions button — only when installed + authenticated */} + {cliStatus.authLoggedIn && ( + + )} +
{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 ? ( + + ) : provider.canLoginFromUi ? ( + + ) : 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 => {

- + {cliStatus.supportsSelfUpdate ? ( + + ) : ( +

+ 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 ? ( - ) : ( + ) : effectiveCliStatus.supportsSelfUpdate ? ( - )} + ) : null} {/* Extensions button — right-aligned */}
    - {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 ? ( + + ) : provider.canLoginFromUi ? ( + + ) : 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 && ( )} + {!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);