feat(multimodel): add free-code runtime and provider status UI
This commit is contained in:
parent
e26310870c
commit
bae3609561
27 changed files with 2780 additions and 88 deletions
825
docs/claude-multimodel-integration-plan.md
Normal file
825
docs/claude-multimodel-integration-plan.md
Normal file
|
|
@ -0,0 +1,825 @@
|
|||
# Claude Multimodel Integration Plan
|
||||
|
||||
## Summary
|
||||
|
||||
`claude_team` will integrate with a single CLI runtime: `claude-multimodel`.
|
||||
|
||||
Inside that runtime, the app will track multiple provider states independently:
|
||||
|
||||
- `anthropic`
|
||||
- `codex`
|
||||
|
||||
The dashboard will show one grouped status banner for the runtime and its providers.
|
||||
That grouped banner should replace the current single-runtime dashboard banner, not sit beside a second provider banner.
|
||||
|
||||
Provider and model selection will happen at process launch time, not in the banner.
|
||||
|
||||
This avoids duplicating auth logic in `claude_team` and keeps `claude-multimodel` responsible
|
||||
for provider login, token storage, and provider capability reporting.
|
||||
|
||||
## Core Decisions
|
||||
|
||||
### 1. Runtime model
|
||||
|
||||
There is one runtime:
|
||||
|
||||
- `claude-multimodel`
|
||||
|
||||
There are not multiple runtime binaries for this feature.
|
||||
`Anthropic` and `Codex` are providers inside the same runtime.
|
||||
|
||||
Important consequence:
|
||||
|
||||
- there is no separate `Codex` binary to install
|
||||
- there is no separate `Codex` version to display
|
||||
- there is no separate `Codex` updater flow
|
||||
|
||||
### 2. Provider model
|
||||
|
||||
Provider status must be tracked independently.
|
||||
|
||||
Supported initial providers:
|
||||
|
||||
- `anthropic`
|
||||
- `codex`
|
||||
|
||||
The user may be authenticated with both at the same time.
|
||||
The UI must show both statuses independently without trying to choose a global active provider.
|
||||
|
||||
This provider model must also remain compatible with future providers that expose
|
||||
multiple internal backend paths under one public provider id.
|
||||
|
||||
Example:
|
||||
|
||||
- public provider: `gemini`
|
||||
- internal runtime backend: `api` or `cli`
|
||||
|
||||
In that scenario, `claude_team` should still treat `gemini` as one provider row.
|
||||
Backend choice is diagnostic/runtime metadata inside the provider, not a second provider id.
|
||||
|
||||
Provider support must be treated per execution mode, not just globally.
|
||||
At minimum, the integration needs to distinguish:
|
||||
|
||||
- interactive team launch / resume
|
||||
- scheduled one-shot execution
|
||||
|
||||
Internal naming rule:
|
||||
|
||||
- app-level provider id: `codex`
|
||||
- runtime/provider label in UI: `Codex`
|
||||
- this maps to the OpenAI/Codex path inside `claude-multimodel`
|
||||
- during migration, runtime-internal naming may still use `openai`; the bridge layer must normalize that to app-level `codex`
|
||||
|
||||
Backward-compatibility rule:
|
||||
|
||||
- existing persisted launches/schedules/teams that do not yet have `providerId` must default safely to `anthropic`
|
||||
- missing `providerId` in old metadata must never block resume, launch, or schedule execution
|
||||
|
||||
### 3. Launch-time selection
|
||||
|
||||
Banner state is informational only.
|
||||
|
||||
Provider selection happens when launching a task/team/process:
|
||||
|
||||
- selected provider
|
||||
- selected model
|
||||
|
||||
Future extension:
|
||||
|
||||
- per-teammate provider
|
||||
- per-teammate model
|
||||
|
||||
Defaulting rule:
|
||||
|
||||
- for backward compatibility, flows with no explicit provider should default to `anthropic`
|
||||
- provider defaulting must be explicit in code and persistence, not inferred from whichever UI tab/control happened to be last open
|
||||
- model defaults must be provider-aware rather than using a single global default such as `opus`
|
||||
- if a provider later adds internal backend selection, backend resolution must not silently change the app-level `providerId`
|
||||
|
||||
## Goals
|
||||
|
||||
- Show runtime status for `claude-multimodel`
|
||||
- Show separate provider status for `Anthropic` and `Codex`
|
||||
- Support login/logout/status checks through the runtime CLI
|
||||
- Reuse the existing binary resolver and terminal-based login modal flow where possible
|
||||
- Avoid reading runtime token/config internals directly from `claude_team`
|
||||
- Keep current team launch flow extensible for provider/model selection
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No separate `Codex` installer
|
||||
- No fake `Codex` download flow in the UI
|
||||
- No separate `Codex` runtime version
|
||||
- No full runtime abstraction layer right now
|
||||
- No per-teammate model/provider implementation in this phase
|
||||
|
||||
## Architecture
|
||||
|
||||
### Runtime ownership
|
||||
|
||||
`claude-multimodel` owns:
|
||||
|
||||
- provider auth flows
|
||||
- provider token storage
|
||||
- provider auth verification
|
||||
- provider model availability reporting
|
||||
|
||||
`claude_team` owns:
|
||||
|
||||
- UI rendering
|
||||
- caching and refreshing status
|
||||
- launch-time provider/model selection
|
||||
- mapping runtime status into app-friendly DTOs
|
||||
- reusing shared CLI probing helpers instead of duplicating binary/version logic
|
||||
- ensuring per-process provider env/args are isolated and do not leak across parallel launches
|
||||
- validating provider support for the concrete execution mode before spawn
|
||||
|
||||
### Service boundary in `claude_team`
|
||||
|
||||
Add a new main-process service:
|
||||
|
||||
- `src/main/services/runtime/ClaudeMultimodelBridgeService.ts`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- resolve runtime binary path
|
||||
- get runtime version
|
||||
- get provider auth status
|
||||
- get provider model lists
|
||||
- aggregate banner DTO
|
||||
- trigger provider login/logout commands
|
||||
|
||||
This service should be separate from `CliInstallerService`.
|
||||
Installer/update concerns and provider/auth concerns are different responsibilities.
|
||||
|
||||
However, the implementation should reuse existing low-level pieces where possible:
|
||||
|
||||
- `ClaudeBinaryResolver`
|
||||
- existing terminal modal login pattern
|
||||
- existing CLI version probing helpers, if they are extracted into a shared utility
|
||||
|
||||
But existing Claude-specific auth/model logic must not be reused as-is for provider rows:
|
||||
|
||||
- current installer auth probing is Anthropic-oriented
|
||||
- current model parsing and display utilities are Claude-oriented
|
||||
- provider rows must be driven by runtime provider data, not inferred from Claude-only helpers
|
||||
|
||||
The service should also enforce bounded status-check behavior:
|
||||
|
||||
- use timeouts for each runtime CLI command
|
||||
- keep partial results if one command fails
|
||||
- cache recent successful results briefly to avoid noisy repeated probes
|
||||
|
||||
## Required CLI Contract in `claude-multimodel`
|
||||
|
||||
The runtime should expose machine-readable commands.
|
||||
|
||||
### 1. Runtime version
|
||||
|
||||
Already available:
|
||||
|
||||
```bash
|
||||
claude-multimodel --version
|
||||
```
|
||||
|
||||
Used for:
|
||||
|
||||
- runtime version display in banner header
|
||||
|
||||
Version display rule:
|
||||
|
||||
- the app should normalize dev/build suffixes before rendering the banner header
|
||||
- example:
|
||||
- raw: `2.1.87-dev.20260401.t145625.sha38c09970 (Claude Code)`
|
||||
- shown: `Claude 2.1.87`
|
||||
|
||||
### 2. Provider auth status
|
||||
|
||||
Add:
|
||||
|
||||
```bash
|
||||
claude-multimodel auth status --json --provider all
|
||||
```
|
||||
|
||||
Contract requirements:
|
||||
|
||||
- non-interactive only
|
||||
- must never open a browser or prompt for user input
|
||||
- must be side-effect free:
|
||||
- no provider switching
|
||||
- no token mutation unless the runtime explicitly performs a safe read-only refresh internally
|
||||
- should return partial provider results when possible instead of failing the entire command on one provider-specific check
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"providers": {
|
||||
"anthropic": {
|
||||
"supported": true,
|
||||
"authenticated": true,
|
||||
"authMethod": "oauth",
|
||||
"verificationState": "verified",
|
||||
"canLoginFromUi": true,
|
||||
"capabilities": {
|
||||
"teamLaunch": true,
|
||||
"oneShot": true
|
||||
}
|
||||
},
|
||||
"codex": {
|
||||
"supported": true,
|
||||
"authenticated": false,
|
||||
"authMethod": null,
|
||||
"verificationState": "verified",
|
||||
"canLoginFromUi": true,
|
||||
"capabilities": {
|
||||
"teamLaunch": true,
|
||||
"oneShot": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- no global `active` provider state belongs in this contract
|
||||
- provider choice happens per launch request, not in global status
|
||||
- `authenticated` means the runtime considers usable credentials/session state present for that provider
|
||||
- `verificationState` must distinguish `verified` from `unknown` / `offline` / `error`
|
||||
- `supported` means the current runtime build knows how to use that provider
|
||||
- `capabilities` means the current runtime build knows which execution modes can use that provider
|
||||
|
||||
### 3. Provider model lists
|
||||
|
||||
Add:
|
||||
|
||||
```bash
|
||||
claude-multimodel model list --json --provider all
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"providers": {
|
||||
"anthropic": {
|
||||
"models": ["opus", "sonnet", "haiku"]
|
||||
},
|
||||
"codex": {
|
||||
"models": ["gpt-5.3-codex", "gpt-5.4", "gpt-5.4-mini"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is intentionally separate from auth status so auth and capability failures can be handled independently.
|
||||
|
||||
Model listing should be available even when the provider is not currently authenticated, whenever the runtime can determine the model set statically.
|
||||
|
||||
### 4. Provider login
|
||||
|
||||
Add:
|
||||
|
||||
```bash
|
||||
claude-multimodel auth login --provider anthropic
|
||||
claude-multimodel auth login --provider codex
|
||||
```
|
||||
|
||||
```bash
|
||||
claude-multimodel auth logout --provider anthropic
|
||||
claude-multimodel auth logout --provider codex
|
||||
```
|
||||
|
||||
This allows `claude_team` to reuse runtime-managed login flows instead of implementing OAuth directly.
|
||||
|
||||
## Status Model in `claude_team`
|
||||
|
||||
Recommended DTO:
|
||||
|
||||
```ts
|
||||
type ProviderId = 'anthropic' | 'codex';
|
||||
|
||||
interface ProviderStatus {
|
||||
providerId: ProviderId;
|
||||
displayName: string;
|
||||
supported: boolean;
|
||||
authenticated: boolean;
|
||||
authMethod: 'oauth' | 'api_key' | 'external' | 'unknown' | null;
|
||||
verificationState: 'verified' | 'unknown' | 'offline' | 'error';
|
||||
statusMessage?: string | null;
|
||||
models: string[];
|
||||
canLoginFromUi: boolean;
|
||||
capabilities: {
|
||||
teamLaunch: boolean;
|
||||
oneShot: boolean;
|
||||
};
|
||||
backend?: {
|
||||
kind: string;
|
||||
label: string;
|
||||
endpointLabel?: string | null;
|
||||
projectId?: string | null;
|
||||
authMethodDetail?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface ClaudeMultimodelDashboardStatus {
|
||||
runtime: {
|
||||
id: 'claude-multimodel';
|
||||
installed: boolean;
|
||||
version: string | null;
|
||||
};
|
||||
providers: ProviderStatus[];
|
||||
}
|
||||
```
|
||||
|
||||
## Dashboard Banner
|
||||
|
||||
One grouped banner should be shown.
|
||||
It should replace the current dashboard runtime banner, not add a second top-level banner.
|
||||
|
||||
### Header
|
||||
|
||||
- runtime name/version, for example: `Claude 2.1.87`
|
||||
- runtime auth-independent actions like `Extensions`
|
||||
|
||||
For `claude-multimodel` mode:
|
||||
|
||||
- keep the runtime version in the header
|
||||
- hide runtime self-update UI
|
||||
- hide binary path UI
|
||||
|
||||
### Provider rows
|
||||
|
||||
One row per provider:
|
||||
|
||||
- `Anthropic`
|
||||
- `Codex`
|
||||
|
||||
Each row may show:
|
||||
|
||||
- authenticated state
|
||||
- auth method
|
||||
- verification state when status is uncertain
|
||||
- model list if available
|
||||
- backend diagnostics when the provider has internal backend resolution
|
||||
- actions:
|
||||
- `Login` when unauthenticated
|
||||
- `Logout` when authenticated
|
||||
- `Re-check` always
|
||||
|
||||
### Important UI rules
|
||||
|
||||
- Do not show a fake `Codex version`
|
||||
- Do not show a separate `Codex installer`
|
||||
- Do not show a fake `Download Codex` action
|
||||
- Do not force a global provider choice from the banner
|
||||
- If a provider has multiple internal backends, show them as provider diagnostics, not as separate provider rows
|
||||
- Partial success must be visible:
|
||||
- Anthropic ok, Codex missing
|
||||
- Codex ok, Anthropic missing
|
||||
|
||||
## Launch Flow Changes
|
||||
|
||||
Current launch flow already supports a team-level `model`.
|
||||
Current UI also already has a provider affordance in [TeamModelSelector.tsx](../../src/renderer/components/team/dialogs/TeamModelSelector.tsx), but its logic is still placeholder/coming-soon. That placeholder currently uses `openai`; it should be normalized or mapped to the app-level provider id `codex` during implementation.
|
||||
|
||||
Current code paths that must be kept in sync:
|
||||
|
||||
- shared types in [team.ts](../../src/shared/types/team.ts)
|
||||
- HTTP launch parsing in [teams.ts](../../src/main/http/teams.ts)
|
||||
- IPC launch/create validation in [teams.ts](../../src/main/ipc/teams.ts)
|
||||
- persisted draft metadata in [TeamMetaStore.ts](../../src/main/services/team/TeamMetaStore.ts)
|
||||
- scheduled one-shot config in [schedule.ts](../../src/shared/types/schedule.ts)
|
||||
- scheduled execution in [ScheduledTaskExecutor.ts](../../src/main/services/schedule/ScheduledTaskExecutor.ts)
|
||||
- provider/model UI helpers in [TeamModelSelector.tsx](../../src/renderer/components/team/dialogs/TeamModelSelector.tsx)
|
||||
- model display parsing in [modelParser.ts](../../src/shared/utils/modelParser.ts)
|
||||
- launch dialog state persistence in [LaunchTeamDialog.tsx](../../src/renderer/components/team/dialogs/LaunchTeamDialog.tsx)
|
||||
- create dialog state persistence in [CreateTeamDialog.tsx](../../src/renderer/components/team/dialogs/CreateTeamDialog.tsx)
|
||||
- saved draft restore in [teams.ts](../../src/main/ipc/teams.ts)
|
||||
- launch param persistence in the renderer store
|
||||
- slash-command metadata in [slashCommands.ts](../../src/shared/utils/slashCommands.ts)
|
||||
|
||||
Next step is to extend launch requests to include provider selection:
|
||||
|
||||
```ts
|
||||
interface TeamLaunchRequest {
|
||||
providerId?: 'anthropic' | 'codex';
|
||||
model?: string;
|
||||
}
|
||||
```
|
||||
|
||||
Future-compatible extension for providers with internal backend preference:
|
||||
|
||||
```ts
|
||||
interface TeamLaunchRequest {
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
model?: string;
|
||||
providerOptions?: {
|
||||
geminiBackendPreference?: 'auto' | 'api' | 'cli';
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Provider selection must be applied only to the spawned child process environment/args.
|
||||
It must not mutate a global app-wide runtime provider state.
|
||||
|
||||
This launch-time provider/model selection must be honored consistently across all spawn paths:
|
||||
|
||||
- manual team launch
|
||||
- draft team launch that redirects into `createTeam`
|
||||
- scheduled/background execution
|
||||
- resume/retry/restart flows
|
||||
- any other non-interactive process launch path that currently inherits team/model settings
|
||||
|
||||
Implementation rule:
|
||||
|
||||
- provider choice must be expressed via child-process-scoped args/env only
|
||||
- conflicting provider env vars must be cleared for that child process
|
||||
- one run using `codex` must not affect another run using `anthropic`
|
||||
|
||||
Future-compatible shape:
|
||||
|
||||
```ts
|
||||
interface TeamLaunchRequest {
|
||||
providerId?: 'anthropic' | 'codex';
|
||||
model?: string;
|
||||
memberProviders?: Record<string, 'anthropic' | 'codex'>;
|
||||
memberModels?: Record<string, string>;
|
||||
}
|
||||
```
|
||||
|
||||
This allows future per-teammate routing without redesigning the API again.
|
||||
|
||||
Launch-time preflight validation should fail fast before spawning the child process when possible:
|
||||
|
||||
- selected provider is unsupported by the current runtime build
|
||||
- selected provider is clearly unauthenticated
|
||||
- selected model is clearly incompatible with the selected provider
|
||||
|
||||
These checks should produce actionable errors for the user, while still treating cached banner model lists as advisory rather than authoritative.
|
||||
|
||||
The same provider/model fields must also be added to the data that currently persists launch intent:
|
||||
|
||||
- `TeamLaunchRequest`
|
||||
- `TeamCreateRequest` for draft-team fallback
|
||||
- `TeamMetaFile` in `team.meta.json`
|
||||
- `ScheduleLaunchConfig`
|
||||
|
||||
Migration rule:
|
||||
|
||||
- when old persisted data has no `providerId`, treat it as `anthropic`
|
||||
- new writes should persist `providerId` explicitly
|
||||
|
||||
UI persistence rule:
|
||||
|
||||
- provider selection must be persisted independently from model selection
|
||||
- remembered model state must be namespaced by provider, or reset safely on provider switch
|
||||
- a previously remembered Anthropic model must not be auto-applied to Codex, and vice versa
|
||||
|
||||
Launch source-of-truth rule:
|
||||
|
||||
- explicit values from the current launch request win
|
||||
- persisted metadata is fallback only
|
||||
- banner/provider status is informational and must never silently override a launch request
|
||||
- provider-internal backend resolution may inform diagnostics, but it must not overwrite explicit launch-time provider options
|
||||
|
||||
Runtime observation rule:
|
||||
|
||||
- launch-time `providerId` / `model` are the requested starting configuration
|
||||
- observed runtime message metadata may later reflect a different actual model after in-session commands such as `/model`
|
||||
- the app must not conflate "requested at launch" with "currently observed in session"
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### Provider auth combinations
|
||||
|
||||
Must support all of these without breaking the banner:
|
||||
|
||||
- Anthropic authenticated, Codex unauthenticated
|
||||
- Codex authenticated, Anthropic unauthenticated
|
||||
- both authenticated
|
||||
- neither authenticated
|
||||
|
||||
All of these must remain first-class supported states in the banner.
|
||||
|
||||
The same rule extends to future providers with internal backends:
|
||||
|
||||
- one provider row may be authenticated while one backend path is degraded
|
||||
- the banner should show provider-level health plus backend diagnostics without splitting into two pseudo-providers
|
||||
|
||||
### Provider supported, but not for this execution mode
|
||||
|
||||
A provider may be authenticated and generally supported, but still unavailable for a specific path:
|
||||
|
||||
- available for one-shot scheduler runs, but not Agent Teams
|
||||
- available for Agent Teams, but not one-shot
|
||||
|
||||
The banner and preflight validation must not flatten these into a single boolean.
|
||||
Capability checks must use the concrete execution mode being launched.
|
||||
|
||||
For providers with internal backends, capability checks may also depend on the resolved backend:
|
||||
|
||||
- provider supported, backend `api` unsupported for this path
|
||||
- provider supported, backend `cli` supported for this path
|
||||
|
||||
That still remains one provider decision surface in `claude_team`.
|
||||
|
||||
### Runtime ok, provider verify uncertain
|
||||
|
||||
If network verification fails:
|
||||
|
||||
- do not downgrade directly to `authenticated = false`
|
||||
- prefer `verificationState = unknown` or `offline`
|
||||
|
||||
### No global active provider
|
||||
|
||||
Because provider choice happens per launch:
|
||||
|
||||
- the status contract must not imply one provider is globally selected
|
||||
- a user can keep both `Anthropic` and `Codex` signed in simultaneously
|
||||
- the banner is informational, not a provider selector
|
||||
|
||||
### Provider selection must not leak between runs
|
||||
|
||||
If one team/process launches with `codex` and another launches with `anthropic`:
|
||||
|
||||
- this must not mutate the banner into a single globally active provider state
|
||||
- launch-specific provider state should stay attached to the launched process/team metadata
|
||||
- dashboard status should continue to reflect provider availability/auth only
|
||||
- child process env must explicitly clear incompatible provider-selection variables before spawn
|
||||
|
||||
### Managed flags vs custom CLI args
|
||||
|
||||
`claude_team` already supports raw `extraCliArgs` / custom CLI args.
|
||||
|
||||
Those args must not be allowed to silently override app-managed launch intent such as:
|
||||
|
||||
- provider selection
|
||||
- model selection
|
||||
- permission mode defaults owned by the app
|
||||
- any future provider-routing flags/env
|
||||
|
||||
The implementation should either:
|
||||
|
||||
- reject conflicting custom args with a validation error, or
|
||||
- define a strict precedence rule where app-managed provider/model flags always win
|
||||
|
||||
but it must not allow ambiguous mixed configuration.
|
||||
|
||||
### Provider-specific model normalization
|
||||
|
||||
Current UI and launch code contain Claude-specific model assumptions, including:
|
||||
|
||||
- `limitContext`
|
||||
- automatic `[1m]` suffixing
|
||||
- defaulting to Claude-family model ids such as `opus[1m]`
|
||||
|
||||
These rules must become provider-aware:
|
||||
|
||||
- Anthropic may keep Claude-specific context/model normalization
|
||||
- Codex must not inherit Claude-only suffix rules
|
||||
- switching provider must not silently rewrite a selected model into an invalid format for that provider
|
||||
|
||||
### Provider-specific selector state
|
||||
|
||||
Current selector state is Anthropic-biased:
|
||||
|
||||
- provider UI is currently hardcoded to Anthropic
|
||||
- model options are static Anthropic options
|
||||
- launch/create dialogs remember one global last-selected model
|
||||
|
||||
The implementation must make selector state provider-aware:
|
||||
|
||||
- provider list must come from supported runtime providers, with unsupported placeholders still disabled
|
||||
- model options must come from the selected provider, not a shared static list
|
||||
- switching provider must either preserve a valid provider-local previous choice or clear to that provider's default
|
||||
- the UI must not display a stale Anthropic label/model when `codex` is selected
|
||||
|
||||
### Resume and restart semantics
|
||||
|
||||
If a team/run is resumed, retried, or restarted later:
|
||||
|
||||
- the persisted launch-time `providerId` and `model` must remain the default for that resumed run
|
||||
- changing banner status or current UI defaults must not silently rewrite old run configuration
|
||||
- explicit user overrides during a new launch are allowed, but implicit fallback to a different provider is not
|
||||
|
||||
The same rule applies to edited schedules and stored drafts:
|
||||
|
||||
- opening an old schedule/draft in a newer UI must not silently rewrite provider/model until the user explicitly saves changes
|
||||
|
||||
If a running session changes model interactively after launch:
|
||||
|
||||
- launch metadata should remain the historical requested starting state
|
||||
- observed session model should come from runtime output/log parsing
|
||||
- resume should continue from the actual session state instead of blindly reimposing old defaults unless the user explicitly requests a fresh launch
|
||||
|
||||
### Draft-team launch fallback
|
||||
|
||||
`claude_team` can redirect a launch request into `createTeam` when only `team.meta.json` exists.
|
||||
|
||||
That fallback must preserve the same provider/model intent:
|
||||
|
||||
- `providerId` must survive the launch -> create redirect
|
||||
- draft metadata must not drop provider selection
|
||||
- create-time validation must match launch-time validation
|
||||
|
||||
### Models unavailable
|
||||
|
||||
If model listing fails but auth status succeeds:
|
||||
|
||||
- keep provider visible
|
||||
- show auth state
|
||||
- omit or gray out model list
|
||||
|
||||
If model listing succeeds for an unauthenticated provider:
|
||||
|
||||
- still show the model list
|
||||
- do not block model visibility on login state
|
||||
|
||||
### Provider-specific model display parsing
|
||||
|
||||
Current shared model display utilities are Claude-oriented.
|
||||
|
||||
The integration must not assume:
|
||||
|
||||
- every model id starts with `claude-`
|
||||
- every provider model can be parsed into Claude family/version semantics
|
||||
|
||||
For non-Claude providers, the UI should fall back to generic provider/model labels unless a provider-specific parser is added intentionally.
|
||||
|
||||
This also applies to in-session command output:
|
||||
|
||||
- `/model` command output or equivalent runtime messages may describe state transitions differently per provider
|
||||
- the UI should not assume Anthropic-specific phrasing when interpreting provider/model changes
|
||||
|
||||
If model lists differ by execution mode in a future runtime build:
|
||||
|
||||
- the contract should evolve via `schemaVersion`
|
||||
- old app versions should continue to degrade safely instead of assuming one global model set
|
||||
|
||||
If model lists are temporarily unavailable for the selected provider in the launch dialog:
|
||||
|
||||
- the UI should still allow safe fallback behavior for backward-compatible Anthropic flows
|
||||
- Codex flows should avoid inventing a default model id that did not come from the runtime or explicit user choice
|
||||
|
||||
### Model list is advisory, not launch authority
|
||||
|
||||
Provider model lists in the banner are helpful capability hints only:
|
||||
|
||||
- the actual launch path must still validate provider/model compatibility at spawn time
|
||||
- cached model lists must not be treated as proof that a launch will succeed
|
||||
- auth changes, subscription changes, or runtime updates may change model availability between refresh and launch
|
||||
|
||||
### Older runtime build
|
||||
|
||||
If the runtime does not yet support the new JSON commands:
|
||||
|
||||
- show runtime status normally
|
||||
- show provider section as unavailable
|
||||
- include a message like `Provider status not supported by current claude-multimodel build`
|
||||
|
||||
### Login/logout refresh behavior
|
||||
|
||||
After a login or logout flow in the terminal modal:
|
||||
|
||||
- the app must invalidate cached provider status
|
||||
- the banner must refresh automatically
|
||||
- a canceled login must not be treated as auth failure
|
||||
|
||||
Refresh handling must also be race-safe:
|
||||
|
||||
- older async refresh results must not overwrite newer ones
|
||||
- concurrent login/logout/re-check actions must not leave the banner in a stale mixed state
|
||||
- provider rows should expose a temporary loading/pending state while a provider-specific action is in flight
|
||||
|
||||
### Running teams during auth changes
|
||||
|
||||
If the user logs in, logs out, or changes provider credentials while teams are already running:
|
||||
|
||||
- the banner should refresh to reflect the new provider status
|
||||
- already-running teams must not be implicitly reconfigured by the dashboard refresh itself
|
||||
- launch-time provider/model metadata for existing runs should remain attached to those runs
|
||||
|
||||
Provider status changes should also avoid leaking sensitive details into UI logs:
|
||||
|
||||
- no token values
|
||||
- no raw OAuth payloads
|
||||
- only high-level auth method / verification state / actionable error text
|
||||
|
||||
### Runtime missing
|
||||
|
||||
If `claude-multimodel` is missing or not executable:
|
||||
|
||||
- show runtime-level failure in the banner header
|
||||
- provider rows should be disabled or shown as unavailable
|
||||
- do not render stale provider state as if it were fresh
|
||||
|
||||
### Slow or hanging status commands
|
||||
|
||||
If one or more runtime CLI status commands are slow or hang:
|
||||
|
||||
- the dashboard must not block indefinitely
|
||||
- show the last good known state when available
|
||||
- mark uncertain sections as stale/unknown instead of replacing them with false negatives
|
||||
|
||||
### Mixed command support during rollout
|
||||
|
||||
During migration, some runtime builds may support:
|
||||
|
||||
- version probing
|
||||
- auth status JSON
|
||||
- but not model list JSON
|
||||
|
||||
The app should degrade gracefully per capability instead of requiring all commands to work at once.
|
||||
|
||||
### Provider-specific status degradation
|
||||
|
||||
If one provider status check succeeds and another degrades:
|
||||
|
||||
- keep the successful provider row fully usable
|
||||
- mark only the failing provider row as `unknown` / `error`
|
||||
- do not collapse the whole banner into a global failure state
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1. Runtime CLI contract
|
||||
|
||||
In `claude-multimodel`:
|
||||
|
||||
- add `auth status --json --provider all`
|
||||
- add `model list --json --provider all`
|
||||
- add `auth login --provider anthropic|codex`
|
||||
- add `auth logout --provider anthropic|codex`
|
||||
|
||||
### Phase 2. Bridge service in `claude_team`
|
||||
|
||||
Add:
|
||||
|
||||
- `src/main/services/runtime/ClaudeMultimodelBridgeService.ts`
|
||||
|
||||
Implement:
|
||||
|
||||
- `getRuntimeVersion()`
|
||||
- `getProviderAuthStatus()`
|
||||
- `getProviderModels()`
|
||||
- `getDashboardStatus()`
|
||||
- `login(providerId)`
|
||||
- `logout(providerId)`
|
||||
|
||||
The service should aggregate the banner DTO from multiple runtime CLI calls while keeping partial results usable.
|
||||
|
||||
### Phase 3. Banner integration
|
||||
|
||||
Refactor the dashboard CLI banner into a grouped runtime/provider banner:
|
||||
|
||||
- runtime header
|
||||
- Anthropic provider row
|
||||
- Codex provider row
|
||||
|
||||
This phase should also explicitly hide runtime path/update controls for `claude-multimodel` mode.
|
||||
|
||||
### Phase 4. Launch integration
|
||||
|
||||
Extend launch/create dialogs and IPC payloads:
|
||||
|
||||
- add `providerId`
|
||||
- wire provider choice into spawned CLI args/env
|
||||
- reuse the existing provider affordance in `TeamModelSelector` instead of creating a second provider selector
|
||||
|
||||
This phase must explicitly define the child-process env mapping for provider selection, including clearing conflicting provider flags.
|
||||
It must also persist launch-time `providerId` alongside `model` in team/run metadata so existing runs remain inspectable after refresh.
|
||||
It must update both the interactive team runtime path and the scheduler one-shot path.
|
||||
|
||||
Phase 4 should also explicitly hide or keep disabled unsupported placeholder providers in `TeamModelSelector` until their runtime support actually exists. In Phase 1/2/3, only `Anthropic` and `Codex` should move out of placeholder behavior.
|
||||
|
||||
### Phase 5. Future teammate-level routing
|
||||
|
||||
Later extension:
|
||||
|
||||
- `memberProviders`
|
||||
- `memberModels`
|
||||
|
||||
This phase should happen only after team-level provider/model selection is stable.
|
||||
It will likely require both `claude_team` changes and runtime support in `claude-multimodel` for teammate-level routing.
|
||||
|
||||
## Why This Plan
|
||||
|
||||
This plan keeps the ownership boundary clean:
|
||||
|
||||
- `claude-multimodel` remains the source of truth for auth and provider capabilities
|
||||
- `claude_team` remains a UI/orchestration layer
|
||||
|
||||
It also avoids the two bad extremes:
|
||||
|
||||
- no duplicated OAuth/token logic in `claude_team`
|
||||
- no premature heavy runtime abstraction layer
|
||||
|
||||
This is the smallest architecture that is still robust enough for:
|
||||
|
||||
- multi-provider status
|
||||
- launch-time provider selection
|
||||
- future per-agent provider/model routing
|
||||
- shared runtime ownership without duplicating auth logic in `claude_team`
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { getErrorMessage } from '@shared/utils/errorHandling';
|
|||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { CliInstallerService } from '../services';
|
||||
import { ClaudeBinaryResolver } from '../services/team/ClaudeBinaryResolver';
|
||||
import type { CliInstallationStatus, IpcResult } from '@shared/types';
|
||||
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||
|
||||
|
|
@ -111,5 +112,6 @@ async function handleInstall(_event: IpcMainInvokeEvent): Promise<IpcResult<void
|
|||
|
||||
function handleInvalidateStatus(_event: IpcMainInvokeEvent): IpcResult<void> {
|
||||
cachedStatus = null;
|
||||
ClaudeBinaryResolver.clearCache();
|
||||
return { success: true, data: undefined };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -37,7 +37,9 @@ import https from 'https';
|
|||
import { tmpdir } from 'os';
|
||||
import { join, posix as pathPosix, win32 as pathWin32 } from 'path';
|
||||
|
||||
import { ClaudeMultimodelBridgeService } from '../runtime/ClaudeMultimodelBridgeService';
|
||||
import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver';
|
||||
import { getConfiguredCliFlavor, getCliFlavorUiOptions } from '../team/cliFlavor';
|
||||
|
||||
import type { CliInstallationStatus, CliInstallerProgress, CliPlatform } from '@shared/types';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
|
@ -123,6 +125,18 @@ const DIAG_PATH_HEAD = 400;
|
|||
const DIAG_HOME_PREVIEW = 120;
|
||||
const DIAG_AUTH_STDOUT_TAIL = 160;
|
||||
|
||||
function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallationStatus {
|
||||
return {
|
||||
...status,
|
||||
providers: status.providers.map((provider) => ({
|
||||
...provider,
|
||||
capabilities: { ...provider.capabilities },
|
||||
backend: provider.backend ? { ...provider.backend } : null,
|
||||
models: [...provider.models],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
|
@ -297,6 +311,7 @@ function resetGatherDiag(diag: CliInstallerStatusRunDiag): void {
|
|||
export class CliInstallerService {
|
||||
private mainWindow: BrowserWindow | null = null;
|
||||
private installing = false;
|
||||
private readonly multimodelBridgeService = new ClaudeMultimodelBridgeService();
|
||||
|
||||
private electronMetaForDiag(): Record<string, unknown> {
|
||||
try {
|
||||
|
|
@ -370,12 +385,48 @@ export class CliInstallerService {
|
|||
return buildEnrichedEnv(binaryPath);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: getStatus
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async getStatus(): Promise<CliInstallationStatus> {
|
||||
const result: CliInstallationStatus = {
|
||||
private createInitialStatus(): CliInstallationStatus {
|
||||
const flavor = getConfiguredCliFlavor();
|
||||
const ui = getCliFlavorUiOptions(flavor);
|
||||
const providers =
|
||||
flavor === 'free-code'
|
||||
? (
|
||||
[
|
||||
{
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
},
|
||||
{
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
},
|
||||
{
|
||||
providerId: 'gemini',
|
||||
displayName: 'Gemini',
|
||||
},
|
||||
] as const
|
||||
).map((provider) => ({
|
||||
...provider,
|
||||
supported: false,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'unknown' as const,
|
||||
statusMessage: 'Checking...',
|
||||
models: [],
|
||||
canLoginFromUi: true,
|
||||
capabilities: {
|
||||
teamLaunch: false,
|
||||
oneShot: false,
|
||||
},
|
||||
backend: null,
|
||||
}))
|
||||
: [];
|
||||
return {
|
||||
flavor,
|
||||
displayName: ui.displayName,
|
||||
supportsSelfUpdate: ui.supportsSelfUpdate,
|
||||
showVersionDetails: ui.showVersionDetails,
|
||||
showBinaryPath: ui.showBinaryPath,
|
||||
installed: false,
|
||||
installedVersion: null,
|
||||
binaryPath: null,
|
||||
|
|
@ -383,7 +434,16 @@ export class CliInstallerService {
|
|||
updateAvailable: false,
|
||||
authLoggedIn: false,
|
||||
authMethod: null,
|
||||
providers,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: getStatus
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async getStatus(): Promise<CliInstallationStatus> {
|
||||
const result = this.createInitialStatus();
|
||||
|
||||
// Run the actual status gathering with an overall timeout.
|
||||
// On timeout, return whatever partial result was collected so far.
|
||||
|
|
@ -437,6 +497,7 @@ export class CliInstallerService {
|
|||
if (binaryPath) {
|
||||
r.installed = true;
|
||||
r.binaryPath = binaryPath;
|
||||
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) });
|
||||
|
||||
try {
|
||||
const { stdout } = await execCli(binaryPath, ['--version'], {
|
||||
|
|
@ -451,13 +512,20 @@ export class CliInstallerService {
|
|||
diag.versionError = getErrorMessage(err);
|
||||
logger.warn('Failed to get CLI version:', diag.versionError);
|
||||
}
|
||||
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) });
|
||||
|
||||
// Auth and GCS version check are independent — run in parallel.
|
||||
// Both mutate `r` directly so partial results survive the outer timeout.
|
||||
await Promise.all([this.checkAuthStatus(binaryPath, r, diag), this.fetchLatestVersion(r)]);
|
||||
await Promise.all([
|
||||
this.checkAuthStatus(binaryPath, r, diag),
|
||||
r.supportsSelfUpdate ? this.fetchLatestVersion(r) : Promise.resolve(),
|
||||
]);
|
||||
} else {
|
||||
// No binary — still check latest version for "install" prompt
|
||||
await this.fetchLatestVersion(r);
|
||||
if (r.supportsSelfUpdate) {
|
||||
await this.fetchLatestVersion(r);
|
||||
}
|
||||
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -472,6 +540,31 @@ export class CliInstallerService {
|
|||
result: CliInstallationStatus,
|
||||
diag: CliInstallerStatusRunDiag
|
||||
): Promise<void> {
|
||||
if (result.flavor === 'free-code') {
|
||||
try {
|
||||
const providers = await this.multimodelBridgeService.getProviderStatuses(
|
||||
binaryPath,
|
||||
(providersSnapshot) => {
|
||||
result.providers = providersSnapshot;
|
||||
result.authLoggedIn = providersSnapshot.some((provider) => provider.authenticated);
|
||||
result.authMethod =
|
||||
providersSnapshot.find((provider) => provider.authenticated)?.authMethod ?? null;
|
||||
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(result) });
|
||||
}
|
||||
);
|
||||
result.providers = providers;
|
||||
result.authLoggedIn = providers.some((provider) => provider.authenticated);
|
||||
result.authMethod =
|
||||
providers.find((provider) => provider.authenticated)?.authMethod ?? null;
|
||||
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(result) });
|
||||
} catch (error) {
|
||||
const msg = getErrorMessage(error);
|
||||
diag.authLastError = msg;
|
||||
logger.warn(`Provider status check failed for claude-multimodel: ${msg}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const doCheck = async (): Promise<void> => {
|
||||
for (let authAttempt = 1; authAttempt <= AUTH_STATUS_MAX_RETRIES; authAttempt++) {
|
||||
diag.authAttempts = authAttempt;
|
||||
|
|
@ -559,6 +652,13 @@ export class CliInstallerService {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
async install(): Promise<void> {
|
||||
if (!getCliFlavorUiOptions(getConfiguredCliFlavor()).supportsSelfUpdate) {
|
||||
const error = 'Updates are disabled for the configured free-code runtime.';
|
||||
logger.warn(error);
|
||||
this.sendProgress({ type: 'error', error });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.installing) {
|
||||
this.sendProgress({ type: 'error', error: 'Installation already in progress' });
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
299
src/main/services/runtime/ClaudeMultimodelBridgeService.ts
Normal file
299
src/main/services/runtime/ClaudeMultimodelBridgeService.ts
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
import { execCli } from '@main/utils/childProcess';
|
||||
import { buildEnrichedEnv } from '@main/utils/cliEnv';
|
||||
import {
|
||||
getCachedShellEnv,
|
||||
getShellPreferredHome,
|
||||
resolveInteractiveShellEnv,
|
||||
} from '@main/utils/shellEnv';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { CliProviderId, CliProviderStatus } from '@shared/types';
|
||||
import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
|
||||
|
||||
const logger = createLogger('ClaudeMultimodelBridgeService');
|
||||
|
||||
const PROVIDER_STATUS_TIMEOUT_MS = 10_000;
|
||||
const PROVIDER_MODELS_TIMEOUT_MS = 10_000;
|
||||
|
||||
interface ProviderStatusCommandResponse {
|
||||
schemaVersion?: number;
|
||||
providers?: Record<
|
||||
string,
|
||||
{
|
||||
supported?: boolean;
|
||||
authenticated?: boolean;
|
||||
authMethod?: string | null;
|
||||
verificationState?: 'verified' | 'unknown' | 'offline' | 'error';
|
||||
canLoginFromUi?: boolean;
|
||||
statusMessage?: string | null;
|
||||
capabilities?: {
|
||||
teamLaunch?: boolean;
|
||||
oneShot?: boolean;
|
||||
};
|
||||
backend?: {
|
||||
kind?: string;
|
||||
label?: string;
|
||||
endpointLabel?: string | null;
|
||||
projectId?: string | null;
|
||||
authMethodDetail?: string | null;
|
||||
} | null;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
interface ProviderModelsCommandResponse {
|
||||
schemaVersion?: number;
|
||||
providers?: Record<
|
||||
string,
|
||||
{
|
||||
models?: Array<string | { id?: string; label?: string; description?: string }>;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
const ORDERED_PROVIDER_IDS: CliProviderId[] = ['anthropic', 'codex', 'gemini'];
|
||||
|
||||
function extractJsonObject<T>(raw: string): T {
|
||||
const trimmed = raw.trim();
|
||||
try {
|
||||
return JSON.parse(trimmed) as T;
|
||||
} catch {
|
||||
const start = trimmed.indexOf('{');
|
||||
const end = trimmed.lastIndexOf('}');
|
||||
if (start >= 0 && end > start) {
|
||||
return JSON.parse(trimmed.slice(start, end + 1)) as T;
|
||||
}
|
||||
throw new Error('No JSON object found in CLI output');
|
||||
}
|
||||
}
|
||||
|
||||
function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStatus {
|
||||
return {
|
||||
providerId,
|
||||
displayName:
|
||||
providerId === 'anthropic' ? 'Anthropic' : providerId === 'codex' ? 'Codex' : 'Gemini',
|
||||
supported: false,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'unknown',
|
||||
statusMessage: null,
|
||||
models: [],
|
||||
canLoginFromUi: true,
|
||||
capabilities: {
|
||||
teamLaunch: false,
|
||||
oneShot: false,
|
||||
},
|
||||
backend: null,
|
||||
};
|
||||
}
|
||||
|
||||
function extractModelIds(
|
||||
models: Array<string | { id?: string; label?: string; description?: string }> | undefined
|
||||
): string[] {
|
||||
if (!models) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return models.flatMap((model) => {
|
||||
if (typeof model === 'string') {
|
||||
return model;
|
||||
}
|
||||
if (typeof model?.id === 'string' && model.id.trim().length > 0) {
|
||||
return model.id.trim();
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
export class ClaudeMultimodelBridgeService {
|
||||
private buildCliEnv(binaryPath: string): NodeJS.ProcessEnv {
|
||||
const shellEnv = getCachedShellEnv() ?? {};
|
||||
const home =
|
||||
getShellPreferredHome() || shellEnv.HOME || process.env.HOME || process.env.USERPROFILE;
|
||||
const env = {
|
||||
...buildEnrichedEnv(binaryPath),
|
||||
...shellEnv,
|
||||
};
|
||||
if (home) {
|
||||
env.HOME = home;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
private buildProviderCliEnv(binaryPath: string, providerId: CliProviderId): NodeJS.ProcessEnv {
|
||||
const env = { ...this.buildCliEnv(binaryPath) };
|
||||
delete env.CLAUDE_CODE_USE_OPENAI;
|
||||
delete env.CLAUDE_CODE_USE_BEDROCK;
|
||||
delete env.CLAUDE_CODE_USE_VERTEX;
|
||||
delete env.CLAUDE_CODE_USE_FOUNDRY;
|
||||
delete env.CLAUDE_CODE_USE_GEMINI;
|
||||
|
||||
if (providerId === 'codex') {
|
||||
env.CLAUDE_CODE_USE_OPENAI = '1';
|
||||
} else if (providerId === 'gemini') {
|
||||
env.CLAUDE_CODE_USE_GEMINI = '1';
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
private async buildGeminiStatus(binaryPath: string): Promise<CliProviderStatus> {
|
||||
const provider = createDefaultProviderStatus('gemini');
|
||||
const env = this.buildProviderCliEnv(binaryPath, 'gemini');
|
||||
|
||||
try {
|
||||
const { stdout } = await execCli(
|
||||
binaryPath,
|
||||
['model', 'list', '--json', '--provider', 'all'],
|
||||
{
|
||||
timeout: PROVIDER_MODELS_TIMEOUT_MS,
|
||||
env,
|
||||
}
|
||||
);
|
||||
const parsed = extractJsonObject<ProviderModelsCommandResponse>(stdout);
|
||||
const models = extractModelIds(parsed.providers?.gemini?.models);
|
||||
if (models.length > 0) {
|
||||
provider.supported = true;
|
||||
provider.models = models;
|
||||
provider.capabilities = {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Gemini model list unavailable: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
|
||||
const authState = await resolveGeminiRuntimeAuth(env);
|
||||
if (authState.authenticated) {
|
||||
provider.authenticated = true;
|
||||
provider.authMethod =
|
||||
authState.authMethod === 'adc_authorized_user' ||
|
||||
authState.authMethod === 'adc_service_account'
|
||||
? `gemini_${authState.authMethod}`
|
||||
: authState.authMethod;
|
||||
provider.verificationState = 'verified';
|
||||
provider.statusMessage = null;
|
||||
if (authState.authMethod === 'cli_oauth_personal') {
|
||||
provider.backend = {
|
||||
kind: 'cli',
|
||||
label: 'Gemini CLI',
|
||||
endpointLabel: 'Code Assist (cloudcode-pa.googleapis.com/v1internal)',
|
||||
projectId: authState.projectId,
|
||||
authMethodDetail: authState.authMethod,
|
||||
};
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
provider.statusMessage =
|
||||
authState.statusMessage ?? 'Set GEMINI_API_KEY or Google ADC to use Gemini.';
|
||||
return provider;
|
||||
}
|
||||
|
||||
async getProviderStatuses(
|
||||
binaryPath: string,
|
||||
onUpdate?: (providers: CliProviderStatus[]) => void
|
||||
): Promise<CliProviderStatus[]> {
|
||||
await resolveInteractiveShellEnv();
|
||||
const env = this.buildCliEnv(binaryPath);
|
||||
|
||||
const [statusResult, modelsResult] = await Promise.allSettled([
|
||||
execCli(binaryPath, ['auth', 'status', '--json', '--provider', 'all'], {
|
||||
timeout: PROVIDER_STATUS_TIMEOUT_MS,
|
||||
env,
|
||||
}),
|
||||
execCli(binaryPath, ['model', 'list', '--json', '--provider', 'all'], {
|
||||
timeout: PROVIDER_MODELS_TIMEOUT_MS,
|
||||
env,
|
||||
}),
|
||||
]);
|
||||
|
||||
const providers = new Map<CliProviderId, CliProviderStatus>(
|
||||
ORDERED_PROVIDER_IDS.map((providerId) => [
|
||||
providerId,
|
||||
createDefaultProviderStatus(providerId),
|
||||
])
|
||||
);
|
||||
|
||||
if (statusResult.status === 'fulfilled') {
|
||||
try {
|
||||
const parsed = extractJsonObject<ProviderStatusCommandResponse>(statusResult.value.stdout);
|
||||
for (const providerId of ORDERED_PROVIDER_IDS.filter((id) => id !== 'gemini')) {
|
||||
const runtimeStatus = parsed.providers?.[providerId];
|
||||
if (!runtimeStatus) continue;
|
||||
providers.set(providerId, {
|
||||
...providers.get(providerId)!,
|
||||
supported: runtimeStatus.supported === true,
|
||||
authenticated: runtimeStatus.authenticated === true,
|
||||
authMethod: runtimeStatus.authMethod ?? null,
|
||||
verificationState: runtimeStatus.verificationState ?? 'unknown',
|
||||
statusMessage: runtimeStatus.statusMessage ?? null,
|
||||
canLoginFromUi: runtimeStatus.canLoginFromUi !== false,
|
||||
capabilities: {
|
||||
teamLaunch: runtimeStatus.capabilities?.teamLaunch === true,
|
||||
oneShot: runtimeStatus.capabilities?.oneShot === true,
|
||||
},
|
||||
backend: runtimeStatus.backend?.kind
|
||||
? {
|
||||
kind: runtimeStatus.backend.kind,
|
||||
label: runtimeStatus.backend.label ?? runtimeStatus.backend.kind,
|
||||
endpointLabel: runtimeStatus.backend.endpointLabel ?? null,
|
||||
projectId: runtimeStatus.backend.projectId ?? null,
|
||||
authMethodDetail: runtimeStatus.backend.authMethodDetail ?? null,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to parse provider auth status JSON: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const message =
|
||||
statusResult.reason instanceof Error
|
||||
? statusResult.reason.message
|
||||
: String(statusResult.reason);
|
||||
logger.warn(`Provider auth status unavailable: ${message}`);
|
||||
for (const providerId of ORDERED_PROVIDER_IDS) {
|
||||
providers.set(providerId, {
|
||||
...providers.get(providerId)!,
|
||||
statusMessage: 'Provider status not supported by current claude-multimodel build',
|
||||
});
|
||||
onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!));
|
||||
}
|
||||
}
|
||||
|
||||
if (modelsResult.status === 'fulfilled') {
|
||||
try {
|
||||
const parsed = extractJsonObject<ProviderModelsCommandResponse>(modelsResult.value.stdout);
|
||||
for (const providerId of ORDERED_PROVIDER_IDS.filter((id) => id !== 'gemini')) {
|
||||
const runtimeModels = extractModelIds(parsed.providers?.[providerId]?.models);
|
||||
if (runtimeModels.length === 0) continue;
|
||||
providers.set(providerId, {
|
||||
...providers.get(providerId)!,
|
||||
models: runtimeModels,
|
||||
});
|
||||
onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to parse provider models JSON: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
providers.set('gemini', await this.buildGeminiStatus(binaryPath));
|
||||
onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!));
|
||||
|
||||
return ORDERED_PROVIDER_IDS.map((providerId) => providers.get(providerId)!);
|
||||
}
|
||||
}
|
||||
113
src/main/services/runtime/geminiRuntimeAuth.ts
Normal file
113
src/main/services/runtime/geminiRuntimeAuth.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export type GeminiGlobalConfig = {
|
||||
geminiBackendPreference?: 'auto' | 'api' | 'cli';
|
||||
geminiResolvedBackend?: 'api' | 'cli';
|
||||
geminiLastAuthMethod?: string;
|
||||
geminiProjectId?: string;
|
||||
};
|
||||
|
||||
export type GeminiRuntimeAuthState = {
|
||||
authenticated: boolean;
|
||||
authMethod: string | null;
|
||||
resolvedBackend: 'auto' | 'api' | 'cli';
|
||||
projectId: string | null;
|
||||
statusMessage: string | null;
|
||||
};
|
||||
|
||||
export async function readGeminiGlobalConfig(
|
||||
env: NodeJS.ProcessEnv
|
||||
): Promise<GeminiGlobalConfig | null> {
|
||||
const home = env.HOME?.trim() || env.USERPROFILE?.trim();
|
||||
const configDir = env.CLAUDE_CONFIG_DIR?.trim();
|
||||
const candidates = configDir
|
||||
? [path.join(configDir, '.config.json')]
|
||||
: home
|
||||
? [path.join(home, '.claude', '.config.json'), path.join(home, '.claude.json')]
|
||||
: [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const raw = await fs.promises.readFile(candidate, 'utf8');
|
||||
return JSON.parse(raw) as GeminiGlobalConfig;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function resolveGeminiRuntimeAuth(
|
||||
env: NodeJS.ProcessEnv
|
||||
): Promise<GeminiRuntimeAuthState> {
|
||||
const config = await readGeminiGlobalConfig(env);
|
||||
const resolvedBackend =
|
||||
env.CLAUDE_CODE_GEMINI_BACKEND?.trim() ||
|
||||
config?.geminiResolvedBackend?.trim() ||
|
||||
config?.geminiBackendPreference?.trim() ||
|
||||
'auto';
|
||||
const authMethod = config?.geminiLastAuthMethod?.trim() ?? null;
|
||||
const projectId =
|
||||
env.GOOGLE_CLOUD_PROJECT?.trim() ||
|
||||
env.GOOGLE_CLOUD_PROJECT_ID?.trim() ||
|
||||
env.GCLOUD_PROJECT?.trim() ||
|
||||
config?.geminiProjectId?.trim() ||
|
||||
null;
|
||||
const hasGeminiApiKey = Boolean(env.GEMINI_API_KEY?.trim());
|
||||
|
||||
if (hasGeminiApiKey) {
|
||||
return {
|
||||
authenticated: true,
|
||||
authMethod: 'api_key',
|
||||
resolvedBackend:
|
||||
resolvedBackend === 'api' || resolvedBackend === 'cli' ? resolvedBackend : 'auto',
|
||||
projectId,
|
||||
statusMessage: null,
|
||||
};
|
||||
}
|
||||
|
||||
if ((authMethod === 'adc_authorized_user' || authMethod === 'adc_service_account') && projectId) {
|
||||
return {
|
||||
authenticated: true,
|
||||
authMethod,
|
||||
resolvedBackend:
|
||||
resolvedBackend === 'api' || resolvedBackend === 'cli' ? resolvedBackend : 'auto',
|
||||
projectId,
|
||||
statusMessage: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (authMethod === 'cli_oauth_personal' && resolvedBackend === 'cli') {
|
||||
return {
|
||||
authenticated: true,
|
||||
authMethod,
|
||||
resolvedBackend: 'cli',
|
||||
projectId,
|
||||
statusMessage: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (authMethod === 'cli_oauth_personal') {
|
||||
return {
|
||||
authenticated: false,
|
||||
authMethod,
|
||||
resolvedBackend:
|
||||
resolvedBackend === 'api' || resolvedBackend === 'cli' ? resolvedBackend : 'auto',
|
||||
projectId,
|
||||
statusMessage:
|
||||
'Gemini CLI OAuth was detected, but the active Gemini backend is not set to cli.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
authMethod,
|
||||
resolvedBackend:
|
||||
resolvedBackend === 'api' || resolvedBackend === 'cli' ? resolvedBackend : 'auto',
|
||||
projectId,
|
||||
statusMessage:
|
||||
'Gemini provider is not configured for runtime use. Set GEMINI_API_KEY or Google ADC credentials (plus GOOGLE_CLOUD_PROJECT when needed) and retry.',
|
||||
};
|
||||
}
|
||||
33
src/main/services/runtime/providerRuntimeEnv.ts
Normal file
33
src/main/services/runtime/providerRuntimeEnv.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { TeamProviderId } from '@shared/types';
|
||||
|
||||
const THIRD_PARTY_PROVIDER_ENV_KEYS = [
|
||||
'CLAUDE_CODE_USE_OPENAI',
|
||||
'CLAUDE_CODE_USE_BEDROCK',
|
||||
'CLAUDE_CODE_USE_VERTEX',
|
||||
'CLAUDE_CODE_USE_FOUNDRY',
|
||||
'CLAUDE_CODE_USE_GEMINI',
|
||||
] as const;
|
||||
|
||||
export function applyProviderRuntimeEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
providerId: TeamProviderId | undefined
|
||||
): NodeJS.ProcessEnv {
|
||||
const resolvedProvider: TeamProviderId =
|
||||
providerId === 'codex' || providerId === 'gemini' ? providerId : 'anthropic';
|
||||
|
||||
for (const key of THIRD_PARTY_PROVIDER_ENV_KEYS) {
|
||||
env[key] = undefined;
|
||||
}
|
||||
|
||||
if (resolvedProvider === 'codex') {
|
||||
env.CLAUDE_CODE_USE_OPENAI = '1';
|
||||
} else if (resolvedProvider === 'gemini') {
|
||||
env.CLAUDE_CODE_USE_GEMINI = '1';
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
export function resolveTeamProviderId(providerId: TeamProviderId | undefined): TeamProviderId {
|
||||
return providerId === 'codex' || providerId === 'gemini' ? providerId : 'anthropic';
|
||||
}
|
||||
|
|
@ -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'],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { getShellPreferredHome, resolveInteractiveShellEnv } from '@main/utils/s
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { getConfiguredCliFlavor } from './cliFlavor';
|
||||
|
||||
async function isExecutable(filePath: string): Promise<boolean> {
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
|
|
@ -165,6 +167,28 @@ async function resolveFromExplicitPath(inputPath: string): Promise<string | null
|
|||
return null;
|
||||
}
|
||||
|
||||
async function resolveFromCandidateList(candidates: string[]): Promise<string | null> {
|
||||
for (const candidate of candidates) {
|
||||
if (await isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getRepoLocalCliCandidates(): string[] {
|
||||
if (process.platform === 'win32') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
return [
|
||||
path.resolve(repoRoot, '..', 'free-code-gemini-research', 'cli'),
|
||||
path.resolve(repoRoot, '..', 'free-code-gemini-research', 'cli-dev'),
|
||||
path.resolve(repoRoot, '..', 'free-code-gemini-research', 'dist', 'cli'),
|
||||
];
|
||||
}
|
||||
|
||||
let cachedPath: string | null | undefined;
|
||||
|
||||
/** Timestamp of last successful cache verification (ms). */
|
||||
|
|
@ -213,6 +237,7 @@ export class ClaudeBinaryResolver {
|
|||
private static async runResolve(): Promise<string | null> {
|
||||
await resolveInteractiveShellEnv();
|
||||
const enrichedPath = buildMergedCliPath(null);
|
||||
const flavor = getConfiguredCliFlavor();
|
||||
|
||||
const overrideRaw = process.env.CLAUDE_CLI_PATH?.trim();
|
||||
if (overrideRaw) {
|
||||
|
|
@ -229,6 +254,19 @@ export class ClaudeBinaryResolver {
|
|||
}
|
||||
}
|
||||
|
||||
if (flavor === 'free-code') {
|
||||
const repoLocalCli = await resolveFromCandidateList(getRepoLocalCliCandidates());
|
||||
if (repoLocalCli) {
|
||||
cachedPath = repoLocalCli;
|
||||
cacheVerifiedAt = Date.now();
|
||||
return cachedPath;
|
||||
}
|
||||
|
||||
// Free-code mode is explicit. If the configured local runtime is missing,
|
||||
// fail closed instead of silently falling back to a different CLI.
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseBinaryName = 'claude';
|
||||
const fromPath = await resolveFromPathEnv(baseBinaryName, enrichedPath);
|
||||
if (fromPath) {
|
||||
|
|
|
|||
43
src/main/services/team/cliFlavor.ts
Normal file
43
src/main/services/team/cliFlavor.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import type { CliFlavor, CliFlavorUiOptions } from '@shared/types';
|
||||
|
||||
import { configManager } from '../infrastructure/ConfigManager';
|
||||
|
||||
export const DEFAULT_CLI_FLAVOR: CliFlavor = 'free-code';
|
||||
|
||||
function parseFlavorOverride(raw: string | undefined): CliFlavor | null {
|
||||
const trimmed = raw?.trim();
|
||||
if (trimmed === 'claude' || trimmed === 'free-code') {
|
||||
return trimmed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getConfiguredCliFlavor(): CliFlavor {
|
||||
const envOverride = parseFlavorOverride(process.env.CLAUDE_TEAM_CLI_FLAVOR);
|
||||
if (envOverride) {
|
||||
return envOverride;
|
||||
}
|
||||
|
||||
const multimodelEnabled = configManager.getConfig().general.multimodelEnabled;
|
||||
return multimodelEnabled ? 'free-code' : 'claude';
|
||||
}
|
||||
|
||||
export function getCliFlavorUiOptions(flavor: CliFlavor): CliFlavorUiOptions {
|
||||
switch (flavor) {
|
||||
case 'free-code':
|
||||
return {
|
||||
displayName: 'free-code-gemini-research',
|
||||
supportsSelfUpdate: false,
|
||||
showVersionDetails: false,
|
||||
showBinaryPath: false,
|
||||
};
|
||||
case 'claude':
|
||||
default:
|
||||
return {
|
||||
displayName: 'Claude CLI',
|
||||
supportsSelfUpdate: true,
|
||||
showVersionDetails: true,
|
||||
showBinaryPath: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -837,8 +837,17 @@ const electronAPI: ElectronAPI = {
|
|||
deleteDraft: async (teamName: string) => {
|
||||
return invokeIpcWithResult<void>(TEAM_DELETE_DRAFT, teamName);
|
||||
},
|
||||
prepareProvisioning: async (cwd?: string) => {
|
||||
return invokeIpcWithResult<TeamProvisioningPrepareResult>(TEAM_PREPARE_PROVISIONING, cwd);
|
||||
prepareProvisioning: async (
|
||||
cwd?: string,
|
||||
providerId?: TeamLaunchRequest['providerId'],
|
||||
providerIds?: TeamLaunchRequest['providerId'][]
|
||||
) => {
|
||||
return invokeIpcWithResult<TeamProvisioningPrepareResult>(
|
||||
TEAM_PREPARE_PROVISIONING,
|
||||
cwd,
|
||||
providerId,
|
||||
providerIds
|
||||
);
|
||||
},
|
||||
createTeam: async (request: TeamCreateRequest) => {
|
||||
return invokeIpcWithResult<TeamCreateResponse>(TEAM_CREATE, request);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { api, isElectronMode } from '@renderer/api';
|
||||
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||
import { SettingsToggle } from '@renderer/components/settings/components';
|
||||
import { TerminalLogPanel } from '@renderer/components/terminal/TerminalLogPanel';
|
||||
import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
|
||||
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
|
||||
|
|
@ -24,11 +26,14 @@ import {
|
|||
HelpCircle,
|
||||
Loader2,
|
||||
LogIn,
|
||||
LogOut,
|
||||
Puzzle,
|
||||
RefreshCw,
|
||||
Terminal,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { CliInstallationStatus, CliProviderId, CliProviderStatus } from '@shared/types';
|
||||
|
||||
// =============================================================================
|
||||
// Border color by state
|
||||
// =============================================================================
|
||||
|
|
@ -141,7 +146,7 @@ const CliCheckingSpinner = ({
|
|||
/>
|
||||
<div>
|
||||
<span className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Checking Claude CLI...
|
||||
Checking AI Providers...
|
||||
</span>
|
||||
{showHint && (
|
||||
<p className="mt-0.5 text-xs" style={{ color: 'var(--color-text-muted)', opacity: 0.7 }}>
|
||||
|
|
@ -162,22 +167,211 @@ interface InstalledBannerProps {
|
|||
cliStatusLoading: boolean;
|
||||
cliStatusError: string | null;
|
||||
isBusy: boolean;
|
||||
multimodelEnabled: boolean;
|
||||
multimodelBusy: boolean;
|
||||
onInstall: () => void;
|
||||
onRefresh: () => void;
|
||||
onMultimodelToggle: (enabled: boolean) => void;
|
||||
onProviderLogin: (providerId: CliProviderId) => void;
|
||||
onProviderLogout: (providerId: CliProviderId) => void;
|
||||
variant: BannerVariant;
|
||||
}
|
||||
|
||||
function getProviderLabel(providerId: CliProviderId): string {
|
||||
switch (providerId) {
|
||||
case 'anthropic':
|
||||
return 'Anthropic';
|
||||
case 'codex':
|
||||
return 'Codex';
|
||||
case 'gemini':
|
||||
return 'Gemini';
|
||||
}
|
||||
}
|
||||
|
||||
function getProviderTerminalCommand(providerId: CliProviderId): {
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
} {
|
||||
if (providerId === 'gemini') {
|
||||
return {
|
||||
args: ['login'],
|
||||
env: { CLAUDE_CODE_USE_GEMINI: '1' },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
args: ['auth', 'login', '--provider', providerId],
|
||||
};
|
||||
}
|
||||
|
||||
function getProviderTerminalLogoutCommand(providerId: CliProviderId): {
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
} {
|
||||
if (providerId === 'gemini') {
|
||||
return {
|
||||
args: ['logout'],
|
||||
env: { CLAUDE_CODE_USE_GEMINI: '1' },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
args: ['auth', 'logout', '--provider', providerId],
|
||||
};
|
||||
}
|
||||
|
||||
function formatProviderStatus(provider: CliProviderStatus): string {
|
||||
if (!provider.supported) {
|
||||
return provider.statusMessage ?? 'Unavailable in current runtime';
|
||||
}
|
||||
if (provider.authenticated) {
|
||||
return provider.authMethod ? `Authenticated via ${provider.authMethod}` : 'Authenticated';
|
||||
}
|
||||
if (provider.verificationState === 'offline') {
|
||||
return provider.statusMessage ?? 'Unable to verify';
|
||||
}
|
||||
return provider.statusMessage ?? 'Not connected';
|
||||
}
|
||||
|
||||
function formatProviderModels(provider: CliProviderStatus): string | null {
|
||||
return provider.models.length > 0 ? provider.models.join(', ') : null;
|
||||
}
|
||||
|
||||
function formatModelBadgeLabel(providerId: CliProviderId, model: string): string {
|
||||
if (providerId === 'anthropic') {
|
||||
return model.replace(/^claude-/, '');
|
||||
}
|
||||
if (providerId === 'codex') {
|
||||
return model.replace(/^gpt-/, '');
|
||||
}
|
||||
if (providerId === 'gemini') {
|
||||
return model.replace(/^gemini-/, '');
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
function ModelBadges({
|
||||
providerId,
|
||||
models,
|
||||
}: {
|
||||
providerId: CliProviderId;
|
||||
models: string[];
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{models.map((model) => (
|
||||
<span
|
||||
key={model}
|
||||
className="rounded-md border px-1.5 py-px font-mono text-[10px] leading-4"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.03)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{formatModelBadgeLabel(providerId, model)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatRuntimeLabel(
|
||||
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>
|
||||
): string | null {
|
||||
if (cliStatus.flavor === 'free-code') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return cliStatus.showVersionDetails && cliStatus.installedVersion
|
||||
? `${cliStatus.displayName} v${cliStatus.installedVersion ?? 'unknown'}`
|
||||
: cliStatus.displayName;
|
||||
}
|
||||
|
||||
function formatRuntimeAuthSummary(
|
||||
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>
|
||||
): string | null {
|
||||
if (cliStatus.flavor === 'free-code' && cliStatus.providers.length > 0) {
|
||||
if (
|
||||
cliStatus.providers.every(
|
||||
(provider) => provider.statusMessage === 'Checking...' && !provider.authenticated
|
||||
)
|
||||
) {
|
||||
return 'Checking providers...';
|
||||
}
|
||||
const supportedProviders = cliStatus.providers.filter((provider) => provider.supported);
|
||||
const denominator =
|
||||
supportedProviders.length > 0 ? supportedProviders.length : cliStatus.providers.length;
|
||||
const connected = (
|
||||
supportedProviders.length > 0 ? supportedProviders : cliStatus.providers
|
||||
).filter((provider) => provider.authenticated).length;
|
||||
|
||||
return `Providers: ${connected}/${denominator} connected`;
|
||||
}
|
||||
|
||||
if (cliStatus.authLoggedIn) {
|
||||
return 'Authenticated';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function createLoadingMultimodelStatus(): CliInstallationStatus {
|
||||
const providers: CliProviderStatus[] = [
|
||||
{ providerId: 'anthropic' as const, displayName: 'Anthropic' },
|
||||
{ providerId: 'codex' as const, displayName: 'Codex' },
|
||||
{ providerId: 'gemini' as const, displayName: 'Gemini' },
|
||||
].map((provider) => ({
|
||||
...provider,
|
||||
supported: false,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'unknown' as const,
|
||||
statusMessage: 'Checking...',
|
||||
models: [],
|
||||
canLoginFromUi: true,
|
||||
capabilities: {
|
||||
teamLaunch: false,
|
||||
oneShot: false,
|
||||
},
|
||||
backend: null,
|
||||
}));
|
||||
|
||||
return {
|
||||
flavor: 'free-code',
|
||||
displayName: 'free-code-gemini-research',
|
||||
supportsSelfUpdate: false,
|
||||
showVersionDetails: false,
|
||||
showBinaryPath: false,
|
||||
installed: true,
|
||||
installedVersion: null,
|
||||
binaryPath: null,
|
||||
latestVersion: null,
|
||||
updateAvailable: false,
|
||||
authLoggedIn: false,
|
||||
authMethod: null,
|
||||
providers,
|
||||
};
|
||||
}
|
||||
|
||||
const InstalledBanner = ({
|
||||
cliStatus,
|
||||
cliStatusLoading,
|
||||
cliStatusError,
|
||||
isBusy,
|
||||
multimodelEnabled,
|
||||
multimodelBusy,
|
||||
onInstall,
|
||||
onRefresh,
|
||||
onMultimodelToggle,
|
||||
onProviderLogin,
|
||||
onProviderLogout,
|
||||
variant,
|
||||
}: InstalledBannerProps): React.JSX.Element => {
|
||||
const openExtensionsTab = useStore((s) => s.openExtensionsTab);
|
||||
const styles = VARIANT_STYLES[variant];
|
||||
const runtimeLabel = formatRuntimeLabel(cliStatus);
|
||||
const runtimeAuthSummary = formatRuntimeAuthSummary(cliStatus);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -189,12 +383,14 @@ const InstalledBanner = ({
|
|||
<Terminal className="size-4 shrink-0" style={{ color: 'var(--color-text-muted)' }} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm" style={{ color: 'var(--color-text)' }}>
|
||||
Claude CLI v{cliStatus.installedVersion ?? 'unknown'}
|
||||
</span>
|
||||
{runtimeLabel && (
|
||||
<span className="text-sm" style={{ color: 'var(--color-text)' }}>
|
||||
{runtimeLabel}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Update / Check for Updates — inline next to version */}
|
||||
{cliStatus.updateAvailable ? (
|
||||
{cliStatus.supportsSelfUpdate && cliStatus.updateAvailable ? (
|
||||
<button
|
||||
onClick={onInstall}
|
||||
disabled={isBusy}
|
||||
|
|
@ -204,7 +400,7 @@ const InstalledBanner = ({
|
|||
<Download className="size-3" />
|
||||
Update to v{cliStatus.latestVersion}
|
||||
</button>
|
||||
) : (
|
||||
) : cliStatus.supportsSelfUpdate ? (
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={cliStatusLoading}
|
||||
|
|
@ -214,15 +410,15 @@ const InstalledBanner = ({
|
|||
<RefreshCw className={cliStatusLoading ? 'size-3 animate-spin' : 'size-3'} />
|
||||
{cliStatusLoading ? 'Checking...' : 'Check for Updates'}
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{cliStatus.authLoggedIn && (
|
||||
{runtimeAuthSummary && (
|
||||
<span className="text-xs" style={{ color: '#4ade80' }}>
|
||||
Authenticated
|
||||
{runtimeAuthSummary}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{cliStatus.binaryPath && (
|
||||
{cliStatus.showBinaryPath && cliStatus.binaryPath && (
|
||||
<button
|
||||
className="truncate font-mono text-xs hover:underline"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
|
|
@ -235,23 +431,146 @@ const InstalledBanner = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Extensions button — only when installed + authenticated */}
|
||||
{cliStatus.authLoggedIn && (
|
||||
<button
|
||||
onClick={openExtensionsTab}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/5"
|
||||
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
<Puzzle className="size-3.5" />
|
||||
Extensions
|
||||
</button>
|
||||
)}
|
||||
<div className="flex shrink-0 items-center gap-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Multimodel
|
||||
</span>
|
||||
{multimodelEnabled && (
|
||||
<span
|
||||
className="rounded-full border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em]"
|
||||
style={{
|
||||
borderColor: 'rgba(245, 158, 11, 0.35)',
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.1)',
|
||||
color: '#fbbf24',
|
||||
}}
|
||||
>
|
||||
Beta
|
||||
</span>
|
||||
)}
|
||||
<SettingsToggle
|
||||
enabled={multimodelEnabled}
|
||||
onChange={onMultimodelToggle}
|
||||
disabled={isBusy || cliStatusLoading || multimodelBusy}
|
||||
/>
|
||||
</div>
|
||||
{/* Extensions button — only when installed + authenticated */}
|
||||
{cliStatus.authLoggedIn && (
|
||||
<button
|
||||
onClick={openExtensionsTab}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/5"
|
||||
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
<Puzzle className="size-3.5" />
|
||||
Extensions
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{cliStatusError && !cliStatusLoading && (
|
||||
<p className="mt-2 text-xs" style={{ color: '#f87171' }}>
|
||||
Failed to check for updates. Check your network connection and try again.
|
||||
</p>
|
||||
)}
|
||||
{cliStatus.providers.length > 0 && (
|
||||
<div
|
||||
className="mt-3 space-y-2 border-t pt-3"
|
||||
style={{ borderColor: 'var(--color-border-subtle)' }}
|
||||
>
|
||||
{cliStatus.providers.map((provider) => {
|
||||
const statusText = formatProviderStatus(provider);
|
||||
const actionDisabled = isBusy || !cliStatus.binaryPath;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={provider.providerId}
|
||||
className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-2 rounded-md px-2 py-2"
|
||||
style={{ backgroundColor: 'rgba(255, 255, 255, 0.02)' }}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--color-text)' }}>
|
||||
{provider.displayName}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs"
|
||||
style={{
|
||||
color: provider.authenticated ? '#4ade80' : 'var(--color-text-muted)',
|
||||
}}
|
||||
>
|
||||
{statusText}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px]"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
{provider.backend?.label && (
|
||||
<span>
|
||||
Backend: {provider.backend.label}
|
||||
{provider.backend.endpointLabel
|
||||
? ` (${provider.backend.endpointLabel})`
|
||||
: ''}
|
||||
</span>
|
||||
)}
|
||||
{provider.models.length === 0 && (
|
||||
<span>Models unavailable for this runtime build</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{provider.authenticated ? (
|
||||
<button
|
||||
onClick={() => onProviderLogout(provider.providerId)}
|
||||
disabled={actionDisabled}
|
||||
className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
<LogOut className="size-3" />
|
||||
Logout
|
||||
</button>
|
||||
) : provider.canLoginFromUi ? (
|
||||
<button
|
||||
onClick={() => onProviderLogin(provider.providerId)}
|
||||
disabled={actionDisabled}
|
||||
className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
<LogIn className="size-3" />
|
||||
Login
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={cliStatusLoading}
|
||||
className="flex items-center gap-1 rounded-md border px-1.5 py-[3px] text-[10px] transition-colors hover:bg-white/5 disabled:opacity-50"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
title={`Re-check ${provider.displayName}`}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cliStatusLoading ? 'size-[11px] animate-spin' : 'size-[11px]'}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{provider.models.length > 0 && (
|
||||
<div className="col-span-2">
|
||||
<ModelBadges providerId={provider.providerId} models={provider.models} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -262,6 +581,8 @@ const InstalledBanner = ({
|
|||
|
||||
export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||
const isElectron = useMemo(() => isElectronMode(), []);
|
||||
const appConfig = useStore((s) => s.appConfig);
|
||||
const updateConfig = useStore((s) => s.updateConfig);
|
||||
const {
|
||||
cliStatus,
|
||||
cliStatusLoading,
|
||||
|
|
@ -281,8 +602,14 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
} = useCliInstaller();
|
||||
|
||||
const [showLoginTerminal, setShowLoginTerminal] = useState(false);
|
||||
const [providerTerminal, setProviderTerminal] = useState<{
|
||||
providerId: CliProviderId;
|
||||
action: 'login' | 'logout';
|
||||
} | null>(null);
|
||||
const [isVerifyingAuth, setIsVerifyingAuth] = useState(false);
|
||||
const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false);
|
||||
const [showTroubleshoot, setShowTroubleshoot] = useState(false);
|
||||
const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isElectron) return;
|
||||
|
|
@ -312,6 +639,54 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
void fetchCliStatus();
|
||||
}, [fetchCliStatus]);
|
||||
|
||||
const handleMultimodelToggle = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
setIsSwitchingFlavor(true);
|
||||
try {
|
||||
await updateConfig('general', { multimodelEnabled: enabled });
|
||||
await invalidateCliStatus();
|
||||
await fetchCliStatus();
|
||||
} finally {
|
||||
setIsSwitchingFlavor(false);
|
||||
}
|
||||
},
|
||||
[fetchCliStatus, invalidateCliStatus, updateConfig]
|
||||
);
|
||||
|
||||
const recheckAuthState = useCallback(() => {
|
||||
setIsVerifyingAuth(true);
|
||||
void (async () => {
|
||||
try {
|
||||
await invalidateCliStatus();
|
||||
await fetchCliStatus();
|
||||
} finally {
|
||||
setIsVerifyingAuth(false);
|
||||
}
|
||||
})();
|
||||
}, [fetchCliStatus, invalidateCliStatus]);
|
||||
|
||||
const handleProviderLogin = useCallback((providerId: CliProviderId) => {
|
||||
setProviderTerminal({ providerId, action: 'login' });
|
||||
}, []);
|
||||
|
||||
const handleProviderLogout = useCallback((providerId: CliProviderId) => {
|
||||
void (async () => {
|
||||
const confirmed = await confirm({
|
||||
title: `Logout from ${getProviderLabel(providerId)}?`,
|
||||
message: 'This will remove the current provider session from the local Claude CLI runtime.',
|
||||
confirmLabel: 'Logout',
|
||||
cancelLabel: 'Cancel',
|
||||
variant: 'danger',
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProviderTerminal({ providerId, action: 'logout' });
|
||||
})();
|
||||
}, []);
|
||||
|
||||
if (!isElectron) return null;
|
||||
|
||||
// Determine variant for styling
|
||||
|
|
@ -328,6 +703,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
|
||||
const variant = getVariant();
|
||||
const styles = VARIANT_STYLES[variant];
|
||||
const providerTerminalCommand = providerTerminal
|
||||
? providerTerminal.action === 'login'
|
||||
? getProviderTerminalCommand(providerTerminal.providerId)
|
||||
: getProviderTerminalLogoutCommand(providerTerminal.providerId)
|
||||
: null;
|
||||
|
||||
// ── Loading / fetch error state ────────────────────────────────────────
|
||||
if (!cliStatus && installerState === 'idle') {
|
||||
|
|
@ -384,7 +764,27 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
);
|
||||
}
|
||||
|
||||
// Loading state: show spinner only while an actual request is in-flight.
|
||||
// Multimodel: render provider cards immediately instead of a generic intermediate block.
|
||||
if (multimodelEnabled) {
|
||||
return (
|
||||
<InstalledBanner
|
||||
cliStatus={createLoadingMultimodelStatus()}
|
||||
cliStatusLoading={cliStatusLoading}
|
||||
cliStatusError={cliStatusError ?? null}
|
||||
isBusy={isBusy}
|
||||
multimodelEnabled={multimodelEnabled}
|
||||
multimodelBusy={isSwitchingFlavor}
|
||||
onInstall={handleInstall}
|
||||
onRefresh={handleRefresh}
|
||||
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
|
||||
onProviderLogin={handleProviderLogin}
|
||||
onProviderLogout={handleProviderLogout}
|
||||
variant="info"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Claude-only mode: keep the generic loading spinner.
|
||||
return <CliCheckingSpinner styles={styles} />;
|
||||
}
|
||||
|
||||
|
|
@ -516,22 +916,28 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
disabled={isBusy}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md px-4 py-2 text-sm font-medium text-white transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: '#3b82f6' }}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
Install Claude CLI
|
||||
</button>
|
||||
{cliStatus.supportsSelfUpdate ? (
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
disabled={isBusy}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md px-4 py-2 text-sm font-medium text-white transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: '#3b82f6' }}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
Install Claude CLI
|
||||
</button>
|
||||
) : (
|
||||
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
The configured free-code runtime was not found.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Installed but not logged in — yellow warning banner
|
||||
if (cliStatus.installed && !cliStatus.authLoggedIn) {
|
||||
if (cliStatus.installed && cliStatus.flavor !== 'free-code' && !cliStatus.authLoggedIn) {
|
||||
if (isVerifyingAuth) {
|
||||
return (
|
||||
<div
|
||||
|
|
@ -565,8 +971,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
Not logged in
|
||||
</p>
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Claude CLI is installed but you are not authenticated. Login is required for team
|
||||
provisioning and AI features.
|
||||
{cliStatus.displayName} is installed but you are not authenticated. Login is
|
||||
required for team provisioning and AI features.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -642,28 +1048,30 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
<li>
|
||||
Open your terminal and run:{' '}
|
||||
<code className="rounded bg-white/5 px-1.5 py-0.5 font-mono text-[11px]">
|
||||
claude auth status
|
||||
{cliStatus.showBinaryPath && cliStatus.binaryPath
|
||||
? `"${cliStatus.binaryPath}" auth status`
|
||||
: 'your configured CLI auth status command'}
|
||||
</code>{' '}
|
||||
— check if it shows "Logged in"
|
||||
</li>
|
||||
<li>
|
||||
If it says logged in but the app doesn't see it, try:{' '}
|
||||
<code className="rounded bg-white/5 px-1.5 py-0.5 font-mono text-[11px]">
|
||||
claude auth logout
|
||||
{cliStatus.showBinaryPath && cliStatus.binaryPath
|
||||
? `"${cliStatus.binaryPath}" auth logout`
|
||||
: 'the runtime logout command'}
|
||||
</code>{' '}
|
||||
then{' '}
|
||||
<code className="rounded bg-white/5 px-1.5 py-0.5 font-mono text-[11px]">
|
||||
claude auth login
|
||||
{cliStatus.showBinaryPath && cliStatus.binaryPath
|
||||
? `"${cliStatus.binaryPath}" auth login`
|
||||
: 'the runtime login command'}
|
||||
</code>{' '}
|
||||
again
|
||||
</li>
|
||||
<li>
|
||||
Make sure{' '}
|
||||
<code className="rounded bg-white/5 px-1.5 py-0.5 font-mono text-[11px]">
|
||||
claude
|
||||
</code>{' '}
|
||||
in your terminal is the same binary the app uses
|
||||
{cliStatus.binaryPath && (
|
||||
Make sure the CLI in your terminal is the same runtime the app uses
|
||||
{cliStatus.showBinaryPath && cliStatus.binaryPath && (
|
||||
<span>
|
||||
:{' '}
|
||||
<code className="rounded bg-white/5 px-1.5 py-0.5 font-mono text-[11px]">
|
||||
|
|
@ -682,7 +1090,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
</div>
|
||||
{showLoginTerminal && cliStatus.binaryPath && (
|
||||
<TerminalModal
|
||||
title="Claude Auth Login"
|
||||
title={`${cliStatus.displayName} Login`}
|
||||
command={cliStatus.binaryPath}
|
||||
args={['auth', 'login']}
|
||||
onClose={() => {
|
||||
|
|
@ -719,14 +1127,45 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
|
||||
// Installed — show version, path, update info
|
||||
return (
|
||||
<InstalledBanner
|
||||
cliStatus={cliStatus}
|
||||
cliStatusLoading={cliStatusLoading}
|
||||
cliStatusError={cliStatusError ?? null}
|
||||
isBusy={isBusy}
|
||||
onInstall={handleInstall}
|
||||
onRefresh={handleRefresh}
|
||||
variant={variant}
|
||||
/>
|
||||
<>
|
||||
<InstalledBanner
|
||||
cliStatus={cliStatus}
|
||||
cliStatusLoading={cliStatusLoading}
|
||||
cliStatusError={cliStatusError ?? null}
|
||||
isBusy={isBusy}
|
||||
multimodelEnabled={multimodelEnabled}
|
||||
multimodelBusy={isSwitchingFlavor}
|
||||
onInstall={handleInstall}
|
||||
onRefresh={handleRefresh}
|
||||
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
|
||||
onProviderLogin={handleProviderLogin}
|
||||
onProviderLogout={handleProviderLogout}
|
||||
variant={variant}
|
||||
/>
|
||||
{providerTerminal && cliStatus.binaryPath && (
|
||||
<TerminalModal
|
||||
title={`${cliStatus.displayName} ${providerTerminal.action === 'login' ? 'Login' : 'Logout'}: ${getProviderLabel(
|
||||
providerTerminal.providerId
|
||||
)}`}
|
||||
command={cliStatus.binaryPath}
|
||||
args={providerTerminalCommand?.args}
|
||||
env={providerTerminalCommand?.env}
|
||||
onClose={() => {
|
||||
setProviderTerminal(null);
|
||||
recheckAuthState();
|
||||
}}
|
||||
onExit={() => {
|
||||
recheckAuthState();
|
||||
}}
|
||||
autoCloseOnSuccessMs={3000}
|
||||
successMessage={
|
||||
providerTerminal.action === 'login' ? 'Authentication updated' : 'Provider logged out'
|
||||
}
|
||||
failureMessage={
|
||||
providerTerminal.action === 'login' ? 'Authentication failed' : 'Logout failed'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -314,6 +314,7 @@ export function useSettingsHandlers({
|
|||
showDockIcon: true,
|
||||
theme: 'dark',
|
||||
defaultTab: 'dashboard',
|
||||
multimodelEnabled: true,
|
||||
claudeRootPath: null,
|
||||
agentLanguage: 'system',
|
||||
autoExpandAIGroups: false,
|
||||
|
|
|
|||
|
|
@ -5,16 +5,22 @@
|
|||
* Shows detection status, version info, download progress, and error states.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { isElectronMode } from '@renderer/api';
|
||||
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||
import { SettingsToggle } from '@renderer/components/settings/components';
|
||||
import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
|
||||
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { formatBytes } from '@renderer/utils/formatters';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Download,
|
||||
Loader2,
|
||||
LogIn,
|
||||
LogOut,
|
||||
Puzzle,
|
||||
RefreshCw,
|
||||
Terminal,
|
||||
|
|
@ -22,8 +28,132 @@ import {
|
|||
|
||||
import { SettingsSectionHeader } from '../components';
|
||||
|
||||
import type { CliInstallationStatus, CliProviderId } from '@shared/types';
|
||||
|
||||
function formatModelBadgeLabel(providerId: CliProviderId, model: string): string {
|
||||
if (providerId === 'anthropic') {
|
||||
return model.replace(/^claude-/, '');
|
||||
}
|
||||
if (providerId === 'codex') {
|
||||
return model.replace(/^gpt-/, '');
|
||||
}
|
||||
if (providerId === 'gemini') {
|
||||
return model.replace(/^gemini-/, '');
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
function ModelBadges({
|
||||
providerId,
|
||||
models,
|
||||
}: {
|
||||
providerId: CliProviderId;
|
||||
models: string[];
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{models.map((model) => (
|
||||
<span
|
||||
key={model}
|
||||
className="rounded-md border px-1.5 py-px font-mono text-[10px] leading-4"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.03)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{formatModelBadgeLabel(providerId, model)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getProviderLabel(providerId: CliProviderId): string {
|
||||
switch (providerId) {
|
||||
case 'anthropic':
|
||||
return 'Anthropic';
|
||||
case 'codex':
|
||||
return 'Codex';
|
||||
case 'gemini':
|
||||
return 'Gemini';
|
||||
}
|
||||
}
|
||||
|
||||
function getProviderTerminalCommand(providerId: CliProviderId): {
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
} {
|
||||
if (providerId === 'gemini') {
|
||||
return {
|
||||
args: ['login'],
|
||||
env: { CLAUDE_CODE_USE_GEMINI: '1' },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
args: ['auth', 'login', '--provider', providerId],
|
||||
};
|
||||
}
|
||||
|
||||
function getProviderTerminalLogoutCommand(providerId: CliProviderId): {
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
} {
|
||||
if (providerId === 'gemini') {
|
||||
return {
|
||||
args: ['logout'],
|
||||
env: { CLAUDE_CODE_USE_GEMINI: '1' },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
args: ['auth', 'logout', '--provider', providerId],
|
||||
};
|
||||
}
|
||||
|
||||
function createLoadingMultimodelStatus(): CliInstallationStatus {
|
||||
const providers: Array<{ providerId: CliProviderId; displayName: string }> = [
|
||||
{ providerId: 'anthropic', displayName: 'Anthropic' },
|
||||
{ providerId: 'codex', displayName: 'Codex' },
|
||||
{ providerId: 'gemini', displayName: 'Gemini' },
|
||||
];
|
||||
|
||||
return {
|
||||
flavor: 'free-code',
|
||||
displayName: 'free-code-gemini-research',
|
||||
supportsSelfUpdate: false,
|
||||
showVersionDetails: false,
|
||||
showBinaryPath: false,
|
||||
installed: true,
|
||||
installedVersion: null,
|
||||
binaryPath: null,
|
||||
latestVersion: null,
|
||||
updateAvailable: false,
|
||||
authLoggedIn: false,
|
||||
authMethod: null,
|
||||
providers: providers.map((provider) => ({
|
||||
...provider,
|
||||
supported: false,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'unknown' as const,
|
||||
statusMessage: 'Checking...',
|
||||
models: [],
|
||||
canLoginFromUi: true,
|
||||
capabilities: {
|
||||
teamLaunch: false,
|
||||
oneShot: false,
|
||||
},
|
||||
backend: null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export const CliStatusSection = (): React.JSX.Element | null => {
|
||||
const isElectron = useMemo(() => isElectronMode(), []);
|
||||
const appConfig = useStore((s) => s.appConfig);
|
||||
const updateConfig = useStore((s) => s.updateConfig);
|
||||
const {
|
||||
cliStatus,
|
||||
installerState,
|
||||
|
|
@ -36,7 +166,18 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
installCli,
|
||||
isBusy,
|
||||
cliStatusLoading,
|
||||
invalidateCliStatus,
|
||||
} = useCliInstaller();
|
||||
const [providerTerminal, setProviderTerminal] = useState<{
|
||||
providerId: CliProviderId;
|
||||
action: 'login' | 'logout';
|
||||
} | null>(null);
|
||||
const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false);
|
||||
const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true;
|
||||
const effectiveCliStatus =
|
||||
!cliStatus && cliStatusLoading && multimodelEnabled
|
||||
? createLoadingMultimodelStatus()
|
||||
: cliStatus;
|
||||
|
||||
useEffect(() => {
|
||||
if (isElectron) {
|
||||
|
|
@ -52,38 +193,118 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
void fetchCliStatus();
|
||||
}, [fetchCliStatus]);
|
||||
|
||||
const handleProviderLogout = useCallback(async (providerId: CliProviderId) => {
|
||||
const confirmed = await confirm({
|
||||
title: `Logout from ${getProviderLabel(providerId)}?`,
|
||||
message: 'This will remove the current provider session from the local Claude CLI runtime.',
|
||||
confirmLabel: 'Logout',
|
||||
cancelLabel: 'Cancel',
|
||||
variant: 'danger',
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProviderTerminal({
|
||||
providerId,
|
||||
action: 'logout',
|
||||
});
|
||||
}, []);
|
||||
|
||||
const recheckStatus = useCallback(() => {
|
||||
void (async () => {
|
||||
await invalidateCliStatus();
|
||||
await fetchCliStatus();
|
||||
})();
|
||||
}, [fetchCliStatus, invalidateCliStatus]);
|
||||
|
||||
const handleMultimodelToggle = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
setIsSwitchingFlavor(true);
|
||||
try {
|
||||
await updateConfig('general', { multimodelEnabled: enabled });
|
||||
await invalidateCliStatus();
|
||||
await fetchCliStatus();
|
||||
} finally {
|
||||
setIsSwitchingFlavor(false);
|
||||
}
|
||||
},
|
||||
[fetchCliStatus, invalidateCliStatus, updateConfig]
|
||||
);
|
||||
|
||||
if (!isElectron) return null;
|
||||
|
||||
const runtimeLabel =
|
||||
effectiveCliStatus?.flavor === 'free-code'
|
||||
? null
|
||||
: effectiveCliStatus &&
|
||||
effectiveCliStatus.showVersionDetails &&
|
||||
effectiveCliStatus.installedVersion
|
||||
? `${effectiveCliStatus.displayName} v${effectiveCliStatus.installedVersion ?? 'unknown'}`
|
||||
: (effectiveCliStatus?.displayName ?? 'Claude CLI');
|
||||
|
||||
const providerTerminalCommand = providerTerminal
|
||||
? providerTerminal.action === 'login'
|
||||
? getProviderTerminalCommand(providerTerminal.providerId)
|
||||
: getProviderTerminalLogoutCommand(providerTerminal.providerId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<SettingsSectionHeader title="Claude CLI" />
|
||||
<SettingsSectionHeader title="CLI Runtime" />
|
||||
<div className="space-y-3 py-2">
|
||||
{/* Loading status */}
|
||||
{!cliStatus && installerState === 'idle' && (
|
||||
{!effectiveCliStatus && installerState === 'idle' && (
|
||||
<div
|
||||
className="flex items-center gap-2 text-sm"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Checking CLI...
|
||||
Checking AI Providers...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status display */}
|
||||
{cliStatus && installerState === 'idle' && (
|
||||
{effectiveCliStatus && installerState === 'idle' && (
|
||||
<div className="space-y-2">
|
||||
{cliStatus.installed ? (
|
||||
{effectiveCliStatus.installed ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Terminal
|
||||
className="size-4 shrink-0"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
/>
|
||||
<span style={{ color: 'var(--color-text)' }}>
|
||||
Claude CLI v{cliStatus.installedVersion ?? 'unknown'}
|
||||
</span>
|
||||
{runtimeLabel && (
|
||||
<span style={{ color: 'var(--color-text)' }}>{runtimeLabel}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="text-xs font-medium"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
Multimodel
|
||||
</span>
|
||||
{multimodelEnabled && (
|
||||
<span
|
||||
className="rounded-full border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em]"
|
||||
style={{
|
||||
borderColor: 'rgba(245, 158, 11, 0.35)',
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.1)',
|
||||
color: '#fbbf24',
|
||||
}}
|
||||
>
|
||||
Beta
|
||||
</span>
|
||||
)}
|
||||
<SettingsToggle
|
||||
enabled={multimodelEnabled}
|
||||
onChange={(value) => void handleMultimodelToggle(value)}
|
||||
disabled={isBusy || cliStatusLoading || isSwitchingFlavor}
|
||||
/>
|
||||
</div>
|
||||
{/* Inline action buttons */}
|
||||
{cliStatus.updateAvailable ? (
|
||||
{effectiveCliStatus.supportsSelfUpdate && effectiveCliStatus.updateAvailable ? (
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
disabled={isBusy}
|
||||
|
|
@ -93,7 +314,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
<Download className="size-3.5" />
|
||||
Update
|
||||
</button>
|
||||
) : (
|
||||
) : effectiveCliStatus.supportsSelfUpdate ? (
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={cliStatusLoading}
|
||||
|
|
@ -115,7 +336,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
{/* Extensions button — right-aligned */}
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -130,20 +351,116 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
Extensions
|
||||
</button>
|
||||
</div>
|
||||
{cliStatus.binaryPath && (
|
||||
{effectiveCliStatus.showBinaryPath && effectiveCliStatus.binaryPath && (
|
||||
<p
|
||||
className="ml-6 truncate text-xs"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
title={cliStatus.binaryPath}
|
||||
title={effectiveCliStatus.binaryPath}
|
||||
>
|
||||
{cliStatus.binaryPath}
|
||||
{effectiveCliStatus.binaryPath}
|
||||
</p>
|
||||
)}
|
||||
{cliStatus.updateAvailable && cliStatus.latestVersion && (
|
||||
<div className="ml-6 flex items-center gap-2">
|
||||
<span className="text-xs" style={{ color: '#60a5fa' }}>
|
||||
v{cliStatus.installedVersion} → v{cliStatus.latestVersion}
|
||||
</span>
|
||||
{effectiveCliStatus.supportsSelfUpdate &&
|
||||
effectiveCliStatus.updateAvailable &&
|
||||
effectiveCliStatus.latestVersion && (
|
||||
<div className="ml-6 flex items-center gap-2">
|
||||
<span className="text-xs" style={{ color: '#60a5fa' }}>
|
||||
v{effectiveCliStatus.installedVersion} → v
|
||||
{effectiveCliStatus.latestVersion}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{effectiveCliStatus.providers.length > 0 && (
|
||||
<div className="ml-6 mt-3 space-y-2">
|
||||
{effectiveCliStatus.providers.map((provider) => (
|
||||
<div
|
||||
key={provider.providerId}
|
||||
className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-2 rounded-md border px-3 py-2"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.02)',
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
{provider.displayName}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: provider.authenticated
|
||||
? '#4ade80'
|
||||
: 'var(--color-text-muted)',
|
||||
}}
|
||||
>
|
||||
{provider.authenticated
|
||||
? provider.authMethod
|
||||
? `Authenticated via ${provider.authMethod}`
|
||||
: 'Authenticated'
|
||||
: provider.statusMessage || 'Not connected'}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[11px]"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
{provider.backend?.label && (
|
||||
<span>Backend: {provider.backend.label}</span>
|
||||
)}
|
||||
{provider.models.length === 0 && (
|
||||
<span>Models unavailable for this runtime build</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{provider.authenticated ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleProviderLogout(provider.providerId)}
|
||||
disabled={!effectiveCliStatus.binaryPath}
|
||||
className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
<LogOut className="size-3" />
|
||||
Logout
|
||||
</button>
|
||||
) : provider.canLoginFromUi ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setProviderTerminal({
|
||||
providerId: provider.providerId,
|
||||
action: 'login',
|
||||
})
|
||||
}
|
||||
disabled={!effectiveCliStatus.binaryPath || !provider.canLoginFromUi}
|
||||
className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
<LogIn className="size-3" />
|
||||
Login
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{provider.models.length > 0 && (
|
||||
<div className="col-span-2">
|
||||
<ModelBadges
|
||||
providerId={provider.providerId}
|
||||
models={provider.models}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -158,7 +475,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
)}
|
||||
|
||||
{/* Install button (CLI not installed) */}
|
||||
{!cliStatus.installed && (
|
||||
{!effectiveCliStatus.installed && effectiveCliStatus.supportsSelfUpdate && (
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
disabled={isBusy}
|
||||
|
|
@ -169,6 +486,11 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
Install Claude CLI
|
||||
</button>
|
||||
)}
|
||||
{!effectiveCliStatus.installed && !effectiveCliStatus.supportsSelfUpdate && (
|
||||
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
The configured free-code runtime was not found.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -270,6 +592,30 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
{providerTerminal && cliStatus?.binaryPath && (
|
||||
<TerminalModal
|
||||
title={`${cliStatus.displayName} ${providerTerminal.action === 'login' ? 'Login' : 'Logout'}: ${getProviderLabel(
|
||||
providerTerminal.providerId
|
||||
)}`}
|
||||
command={cliStatus.binaryPath}
|
||||
args={providerTerminalCommand?.args}
|
||||
env={providerTerminalCommand?.env}
|
||||
onClose={() => {
|
||||
setProviderTerminal(null);
|
||||
recheckStatus();
|
||||
}}
|
||||
onExit={() => {
|
||||
recheckStatus();
|
||||
}}
|
||||
autoCloseOnSuccessMs={3000}
|
||||
successMessage={
|
||||
providerTerminal.action === 'login' ? 'Authentication updated' : 'Provider logged out'
|
||||
}
|
||||
failureMessage={
|
||||
providerTerminal.action === 'login' ? 'Authentication failed' : 'Logout failed'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ interface EmbeddedTerminalProps {
|
|||
args?: string[];
|
||||
/** Working directory */
|
||||
cwd?: string;
|
||||
/** Environment variables merged into the PTY process env */
|
||||
env?: Record<string, string>;
|
||||
/** Callback when PTY process exits */
|
||||
onExit?: (exitCode: number) => void;
|
||||
/** CSS class for container */
|
||||
|
|
@ -26,6 +28,7 @@ export const EmbeddedTerminal = ({
|
|||
command,
|
||||
args,
|
||||
cwd,
|
||||
env,
|
||||
onExit,
|
||||
className,
|
||||
}: EmbeddedTerminalProps): React.JSX.Element => {
|
||||
|
|
@ -99,6 +102,7 @@ export const EmbeddedTerminal = ({
|
|||
...(command ? { command } : {}),
|
||||
...(args ? { args } : {}),
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(env ? { env } : {}),
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ interface TerminalModalProps {
|
|||
args?: string[];
|
||||
/** Working directory */
|
||||
cwd?: string;
|
||||
/** Environment variables merged into the PTY process env */
|
||||
env?: Record<string, string>;
|
||||
/** Called when the modal should close */
|
||||
onClose: () => void;
|
||||
/** Called when the PTY process exits */
|
||||
|
|
@ -31,6 +33,7 @@ export function TerminalModal({
|
|||
command,
|
||||
args,
|
||||
cwd,
|
||||
env,
|
||||
onClose,
|
||||
onExit,
|
||||
autoCloseOnSuccessMs = 0,
|
||||
|
|
@ -116,7 +119,7 @@ export function TerminalModal({
|
|||
|
||||
{/* Terminal area — always visible, status bar overlaid at bottom */}
|
||||
<div className="relative flex min-h-0 flex-1 flex-col p-2">
|
||||
<EmbeddedTerminal command={command} args={args} cwd={cwd} onExit={handleExit} />
|
||||
<EmbeddedTerminal command={command} args={args} cwd={cwd} env={env} onExit={handleExit} />
|
||||
|
||||
{exited !== null && (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -1167,6 +1167,11 @@ export function initializeNotificationListeners(): () => void {
|
|||
cliInstallerError: progress.error ?? 'Unknown error',
|
||||
});
|
||||
break;
|
||||
case 'status':
|
||||
if (progress.status) {
|
||||
useStore.setState({ cliStatus: progress.status });
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
if (typeof cleanup === 'function') {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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) */
|
||||
|
|
|
|||
160
test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts
Normal file
160
test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
// @vitest-environment node
|
||||
import type { PathLike } from 'fs';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const execCliMock = vi.fn();
|
||||
const buildEnrichedEnvMock = vi.fn<(binaryPath: string) => NodeJS.ProcessEnv>();
|
||||
const getCachedShellEnvMock = vi.fn<() => NodeJS.ProcessEnv | null>();
|
||||
const getShellPreferredHomeMock = vi.fn<() => string>();
|
||||
const resolveInteractiveShellEnvMock = vi.fn<() => Promise<NodeJS.ProcessEnv>>();
|
||||
const readFileMock = vi.fn<(path: PathLike, encoding: BufferEncoding) => Promise<string>>();
|
||||
|
||||
vi.mock('@main/utils/childProcess', () => ({
|
||||
execCli: (...args: Parameters<typeof execCliMock>) => execCliMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@main/utils/cliEnv', () => ({
|
||||
buildEnrichedEnv: (binaryPath: string) => buildEnrichedEnvMock(binaryPath),
|
||||
}));
|
||||
|
||||
vi.mock('@main/utils/shellEnv', () => ({
|
||||
getCachedShellEnv: () => getCachedShellEnvMock(),
|
||||
getShellPreferredHome: () => getShellPreferredHomeMock(),
|
||||
resolveInteractiveShellEnv: () => resolveInteractiveShellEnvMock(),
|
||||
}));
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
default: {
|
||||
promises: {
|
||||
readFile: (filePath: PathLike, encoding: BufferEncoding) => readFileMock(filePath, encoding),
|
||||
},
|
||||
},
|
||||
promises: {
|
||||
readFile: (filePath: PathLike, encoding: BufferEncoding) => readFileMock(filePath, encoding),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ClaudeMultimodelBridgeService', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
buildEnrichedEnvMock.mockReturnValue({});
|
||||
getCachedShellEnvMock.mockReturnValue({});
|
||||
getShellPreferredHomeMock.mockReturnValue('/Users/tester');
|
||||
resolveInteractiveShellEnvMock.mockResolvedValue({});
|
||||
readFileMock.mockImplementation(async (filePath) => {
|
||||
if (String(filePath) === '/Users/tester/.claude.json') {
|
||||
return JSON.stringify({
|
||||
geminiResolvedBackend: 'cli',
|
||||
geminiLastAuthMethod: 'cli_oauth_personal',
|
||||
geminiProjectId: 'demo-project',
|
||||
});
|
||||
}
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
});
|
||||
});
|
||||
|
||||
it('parses object-based model lists and exposes Gemini runtime status', async () => {
|
||||
execCliMock.mockImplementation(async (_binaryPath, args, options) => {
|
||||
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
||||
const env = options?.env ?? {};
|
||||
|
||||
if (normalizedArgs === 'auth status --json --provider all') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
providers: {
|
||||
anthropic: {
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'oauth_token',
|
||||
verificationState: 'verified',
|
||||
canLoginFromUi: true,
|
||||
capabilities: { teamLaunch: true, oneShot: true },
|
||||
backend: { kind: 'anthropic', label: 'Anthropic' },
|
||||
},
|
||||
codex: {
|
||||
supported: true,
|
||||
authenticated: false,
|
||||
verificationState: 'verified',
|
||||
canLoginFromUi: true,
|
||||
statusMessage: 'Not connected',
|
||||
capabilities: { teamLaunch: true, oneShot: true },
|
||||
backend: { kind: 'openai', label: 'OpenAI' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedArgs === 'model list --json --provider all' && env.CLAUDE_CODE_USE_GEMINI === '1') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
providers: {
|
||||
gemini: {
|
||||
models: [{ id: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedArgs === 'model list --json --provider all') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
providers: {
|
||||
anthropic: {
|
||||
models: [{ id: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5' }],
|
||||
},
|
||||
codex: {
|
||||
models: [{ id: 'gpt-5-codex', label: 'GPT-5 Codex' }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected execCli call: ${normalizedArgs}`);
|
||||
});
|
||||
|
||||
const { ClaudeMultimodelBridgeService } = await import(
|
||||
'@main/services/runtime/ClaudeMultimodelBridgeService'
|
||||
);
|
||||
const service = new ClaudeMultimodelBridgeService();
|
||||
|
||||
const providers = await service.getProviderStatuses('/mock/free-code');
|
||||
|
||||
expect(providers).toHaveLength(3);
|
||||
expect(providers[0]).toMatchObject({
|
||||
providerId: 'anthropic',
|
||||
authenticated: true,
|
||||
models: ['claude-sonnet-4-5'],
|
||||
});
|
||||
expect(providers[1]).toMatchObject({
|
||||
providerId: 'codex',
|
||||
authenticated: false,
|
||||
models: ['gpt-5-codex'],
|
||||
statusMessage: 'Not connected',
|
||||
});
|
||||
expect(providers[2]).toMatchObject({
|
||||
providerId: 'gemini',
|
||||
displayName: 'Gemini',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
models: ['gemini-2.5-pro'],
|
||||
canLoginFromUi: true,
|
||||
authMethod: 'cli_oauth_personal',
|
||||
backend: {
|
||||
kind: 'cli',
|
||||
label: 'Gemini CLI',
|
||||
endpointLabel: 'Code Assist (cloudcode-pa.googleapis.com/v1internal)',
|
||||
projectId: 'demo-project',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
29
test/main/services/runtime/providerRuntimeEnv.test.ts
Normal file
29
test/main/services/runtime/providerRuntimeEnv.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
applyProviderRuntimeEnv,
|
||||
resolveTeamProviderId,
|
||||
} from '@main/services/runtime/providerRuntimeEnv';
|
||||
|
||||
describe('providerRuntimeEnv', () => {
|
||||
it('enables Gemini runtime mode and clears other third-party provider flags', () => {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
CLAUDE_CODE_USE_OPENAI: '1',
|
||||
CLAUDE_CODE_USE_GEMINI: undefined,
|
||||
CLAUDE_CODE_USE_BEDROCK: '1',
|
||||
};
|
||||
|
||||
const result = applyProviderRuntimeEnv(env, 'gemini');
|
||||
|
||||
expect(result.CLAUDE_CODE_USE_GEMINI).toBe('1');
|
||||
expect(result.CLAUDE_CODE_USE_OPENAI).toBeUndefined();
|
||||
expect(result.CLAUDE_CODE_USE_BEDROCK).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves gemini as a valid team provider id', () => {
|
||||
expect(resolveTeamProviderId('gemini')).toBe('gemini');
|
||||
expect(resolveTeamProviderId('codex')).toBe('codex');
|
||||
expect(resolveTeamProviderId(undefined)).toBe('anthropic');
|
||||
});
|
||||
});
|
||||
94
test/main/services/team/ClaudeBinaryResolver.test.ts
Normal file
94
test/main/services/team/ClaudeBinaryResolver.test.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// @vitest-environment node
|
||||
import type { PathLike } from 'fs';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mockBuildMergedCliPath = vi.fn<(binaryPath: string | null) => string>();
|
||||
const mockGetShellPreferredHome = vi.fn<() => string>();
|
||||
const mockResolveInteractiveShellEnv = vi.fn<() => Promise<NodeJS.ProcessEnv>>();
|
||||
const mockGetConfiguredCliFlavor = vi.fn<() => 'claude' | 'free-code'>();
|
||||
|
||||
const accessMock = vi.fn<(filePath: PathLike, mode?: number) => Promise<void>>();
|
||||
const statMock = vi.fn<
|
||||
(filePath: PathLike) => Promise<{ isFile: () => boolean }>
|
||||
>();
|
||||
const readdirMock = vi.fn<(filePath: PathLike) => Promise<string[]>>();
|
||||
|
||||
vi.mock('@main/utils/cliPathMerge', () => ({
|
||||
buildMergedCliPath: (binaryPath: string | null) => mockBuildMergedCliPath(binaryPath),
|
||||
}));
|
||||
|
||||
vi.mock('@main/utils/shellEnv', () => ({
|
||||
getShellPreferredHome: () => mockGetShellPreferredHome(),
|
||||
resolveInteractiveShellEnv: () => mockResolveInteractiveShellEnv(),
|
||||
}));
|
||||
|
||||
vi.mock('@main/services/team/cliFlavor', () => ({
|
||||
getConfiguredCliFlavor: () => mockGetConfiguredCliFlavor(),
|
||||
}));
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
default: {
|
||||
constants: { X_OK: 1 },
|
||||
promises: {
|
||||
access: (filePath: PathLike, mode?: number) => accessMock(filePath, mode),
|
||||
stat: (filePath: PathLike) => statMock(filePath),
|
||||
readdir: (filePath: PathLike) => readdirMock(filePath),
|
||||
},
|
||||
},
|
||||
constants: { X_OK: 1 },
|
||||
promises: {
|
||||
access: (filePath: PathLike, mode?: number) => accessMock(filePath, mode),
|
||||
stat: (filePath: PathLike) => statMock(filePath),
|
||||
readdir: (filePath: PathLike) => readdirMock(filePath),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ClaudeBinaryResolver', () => {
|
||||
const originalPlatform = process.platform;
|
||||
const originalCwd = process.cwd;
|
||||
const workspaceRoot = '/Users/belief/dev/projects/claude/claude_team_freecode';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
mockBuildMergedCliPath.mockReturnValue('/usr/local/bin:/usr/bin');
|
||||
mockGetShellPreferredHome.mockReturnValue('/Users/tester');
|
||||
mockResolveInteractiveShellEnv.mockResolvedValue({});
|
||||
mockGetConfiguredCliFlavor.mockReturnValue('free-code');
|
||||
readdirMock.mockResolvedValue([]);
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'darwin',
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
process.cwd = vi.fn(() => workspaceRoot);
|
||||
delete process.env.CLAUDE_CLI_PATH;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
process.cwd = originalCwd;
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('resolves free-code runtime from the free-code-gemini-research sibling repo', async () => {
|
||||
const expectedBinary = '/Users/belief/dev/projects/claude/free-code-gemini-research/cli';
|
||||
|
||||
accessMock.mockImplementation(async (filePath) => {
|
||||
if (filePath === expectedBinary) {
|
||||
return;
|
||||
}
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
const { ClaudeBinaryResolver } = await import('@main/services/team/ClaudeBinaryResolver');
|
||||
ClaudeBinaryResolver.clearCache();
|
||||
|
||||
await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary);
|
||||
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
|
||||
});
|
||||
});
|
||||
55
test/main/services/team/cliFlavor.test.ts
Normal file
55
test/main/services/team/cliFlavor.test.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// @vitest-environment node
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const getConfigMock = vi.fn();
|
||||
|
||||
vi.mock('@main/services/infrastructure/ConfigManager', () => ({
|
||||
configManager: {
|
||||
getConfig: () => getConfigMock(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('cliFlavor', () => {
|
||||
afterEach(() => {
|
||||
delete process.env.CLAUDE_TEAM_CLI_FLAVOR;
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('uses multimodel runtime by default when config enables it', async () => {
|
||||
getConfigMock.mockReturnValue({
|
||||
general: {
|
||||
multimodelEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { getConfiguredCliFlavor } = await import('@main/services/team/cliFlavor');
|
||||
|
||||
expect(getConfiguredCliFlavor()).toBe('free-code');
|
||||
});
|
||||
|
||||
it('uses claude runtime when multimodel is disabled in config', async () => {
|
||||
getConfigMock.mockReturnValue({
|
||||
general: {
|
||||
multimodelEnabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { getConfiguredCliFlavor } = await import('@main/services/team/cliFlavor');
|
||||
|
||||
expect(getConfiguredCliFlavor()).toBe('claude');
|
||||
});
|
||||
|
||||
it('lets env override the persisted config', async () => {
|
||||
process.env.CLAUDE_TEAM_CLI_FLAVOR = 'claude';
|
||||
getConfigMock.mockReturnValue({
|
||||
general: {
|
||||
multimodelEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { getConfiguredCliFlavor } = await import('@main/services/team/cliFlavor');
|
||||
|
||||
expect(getConfiguredCliFlavor()).toBe('claude');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue