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:
|
d3-force:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.0.0
|
version: 3.0.0
|
||||||
|
lucide-react:
|
||||||
|
specifier: '>=0.300.0'
|
||||||
|
version: 0.577.0(react@18.3.1)
|
||||||
react:
|
react:
|
||||||
specifier: ^18.0.0
|
specifier: ^18.0.0
|
||||||
version: 18.3.1
|
version: 18.3.1
|
||||||
|
|
@ -15340,6 +15343,14 @@ snapshots:
|
||||||
chai: 5.3.3
|
chai: 5.3.3
|
||||||
tinyrainbow: 2.0.0
|
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))':
|
'@vitest/mocker@3.2.4(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/spy': 3.2.4
|
'@vitest/spy': 3.2.4
|
||||||
|
|
@ -19287,6 +19298,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
yallist: 4.0.0
|
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):
|
lucide-react@0.577.0(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
|
|
@ -22802,7 +22817,7 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/chai': 5.2.3
|
'@types/chai': 5.2.3
|
||||||
'@vitest/expect': 3.2.4
|
'@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/pretty-format': 3.2.4
|
||||||
'@vitest/runner': 3.2.4
|
'@vitest/runner': 3.2.4
|
||||||
'@vitest/snapshot': 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 { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
import type { CliInstallerService } from '../services';
|
import type { CliInstallerService } from '../services';
|
||||||
|
import { ClaudeBinaryResolver } from '../services/team/ClaudeBinaryResolver';
|
||||||
import type { CliInstallationStatus, IpcResult } from '@shared/types';
|
import type { CliInstallationStatus, IpcResult } from '@shared/types';
|
||||||
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||||
|
|
||||||
|
|
@ -111,5 +112,6 @@ async function handleInstall(_event: IpcMainInvokeEvent): Promise<IpcResult<void
|
||||||
|
|
||||||
function handleInvalidateStatus(_event: IpcMainInvokeEvent): IpcResult<void> {
|
function handleInvalidateStatus(_event: IpcMainInvokeEvent): IpcResult<void> {
|
||||||
cachedStatus = null;
|
cachedStatus = null;
|
||||||
|
ClaudeBinaryResolver.clearCache();
|
||||||
return { success: true, data: undefined };
|
return { success: true, data: undefined };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -286,6 +286,7 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V
|
||||||
'showDockIcon',
|
'showDockIcon',
|
||||||
'theme',
|
'theme',
|
||||||
'defaultTab',
|
'defaultTab',
|
||||||
|
'multimodelEnabled',
|
||||||
'claudeRootPath',
|
'claudeRootPath',
|
||||||
'agentLanguage',
|
'agentLanguage',
|
||||||
'autoExpandAIGroups',
|
'autoExpandAIGroups',
|
||||||
|
|
@ -328,6 +329,12 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V
|
||||||
}
|
}
|
||||||
result.defaultTab = value;
|
result.defaultTab = value;
|
||||||
break;
|
break;
|
||||||
|
case 'multimodelEnabled':
|
||||||
|
if (typeof value !== 'boolean') {
|
||||||
|
return { valid: false, error: 'general.multimodelEnabled must be a boolean' };
|
||||||
|
}
|
||||||
|
result.multimodelEnabled = value;
|
||||||
|
break;
|
||||||
case 'claudeRootPath':
|
case 'claudeRootPath':
|
||||||
if (value === null) {
|
if (value === null) {
|
||||||
result.claudeRootPath = null;
|
result.claudeRootPath = null;
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,9 @@ import https from 'https';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
import { join, posix as pathPosix, win32 as pathWin32 } from 'path';
|
import { join, posix as pathPosix, win32 as pathWin32 } from 'path';
|
||||||
|
|
||||||
|
import { ClaudeMultimodelBridgeService } from '../runtime/ClaudeMultimodelBridgeService';
|
||||||
import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver';
|
import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver';
|
||||||
|
import { getConfiguredCliFlavor, getCliFlavorUiOptions } from '../team/cliFlavor';
|
||||||
|
|
||||||
import type { CliInstallationStatus, CliInstallerProgress, CliPlatform } from '@shared/types';
|
import type { CliInstallationStatus, CliInstallerProgress, CliPlatform } from '@shared/types';
|
||||||
import type { BrowserWindow } from 'electron';
|
import type { BrowserWindow } from 'electron';
|
||||||
|
|
@ -123,6 +125,18 @@ const DIAG_PATH_HEAD = 400;
|
||||||
const DIAG_HOME_PREVIEW = 120;
|
const DIAG_HOME_PREVIEW = 120;
|
||||||
const DIAG_AUTH_STDOUT_TAIL = 160;
|
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
|
// Helpers
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -297,6 +311,7 @@ function resetGatherDiag(diag: CliInstallerStatusRunDiag): void {
|
||||||
export class CliInstallerService {
|
export class CliInstallerService {
|
||||||
private mainWindow: BrowserWindow | null = null;
|
private mainWindow: BrowserWindow | null = null;
|
||||||
private installing = false;
|
private installing = false;
|
||||||
|
private readonly multimodelBridgeService = new ClaudeMultimodelBridgeService();
|
||||||
|
|
||||||
private electronMetaForDiag(): Record<string, unknown> {
|
private electronMetaForDiag(): Record<string, unknown> {
|
||||||
try {
|
try {
|
||||||
|
|
@ -370,12 +385,48 @@ export class CliInstallerService {
|
||||||
return buildEnrichedEnv(binaryPath);
|
return buildEnrichedEnv(binaryPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
private createInitialStatus(): CliInstallationStatus {
|
||||||
// Public: getStatus
|
const flavor = getConfiguredCliFlavor();
|
||||||
// ---------------------------------------------------------------------------
|
const ui = getCliFlavorUiOptions(flavor);
|
||||||
|
const providers =
|
||||||
async getStatus(): Promise<CliInstallationStatus> {
|
flavor === 'free-code'
|
||||||
const result: CliInstallationStatus = {
|
? (
|
||||||
|
[
|
||||||
|
{
|
||||||
|
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,
|
installed: false,
|
||||||
installedVersion: null,
|
installedVersion: null,
|
||||||
binaryPath: null,
|
binaryPath: null,
|
||||||
|
|
@ -383,7 +434,16 @@ export class CliInstallerService {
|
||||||
updateAvailable: false,
|
updateAvailable: false,
|
||||||
authLoggedIn: false,
|
authLoggedIn: false,
|
||||||
authMethod: null,
|
authMethod: null,
|
||||||
|
providers,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public: getStatus
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async getStatus(): Promise<CliInstallationStatus> {
|
||||||
|
const result = this.createInitialStatus();
|
||||||
|
|
||||||
// Run the actual status gathering with an overall timeout.
|
// Run the actual status gathering with an overall timeout.
|
||||||
// On timeout, return whatever partial result was collected so far.
|
// On timeout, return whatever partial result was collected so far.
|
||||||
|
|
@ -437,6 +497,7 @@ export class CliInstallerService {
|
||||||
if (binaryPath) {
|
if (binaryPath) {
|
||||||
r.installed = true;
|
r.installed = true;
|
||||||
r.binaryPath = binaryPath;
|
r.binaryPath = binaryPath;
|
||||||
|
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execCli(binaryPath, ['--version'], {
|
const { stdout } = await execCli(binaryPath, ['--version'], {
|
||||||
|
|
@ -451,13 +512,20 @@ export class CliInstallerService {
|
||||||
diag.versionError = getErrorMessage(err);
|
diag.versionError = getErrorMessage(err);
|
||||||
logger.warn('Failed to get CLI version:', diag.versionError);
|
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.
|
// Auth and GCS version check are independent — run in parallel.
|
||||||
// Both mutate `r` directly so partial results survive the outer timeout.
|
// 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 {
|
} else {
|
||||||
// No binary — still check latest version for "install" prompt
|
// 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,
|
result: CliInstallationStatus,
|
||||||
diag: CliInstallerStatusRunDiag
|
diag: CliInstallerStatusRunDiag
|
||||||
): Promise<void> {
|
): 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> => {
|
const doCheck = async (): Promise<void> => {
|
||||||
for (let authAttempt = 1; authAttempt <= AUTH_STATUS_MAX_RETRIES; authAttempt++) {
|
for (let authAttempt = 1; authAttempt <= AUTH_STATUS_MAX_RETRIES; authAttempt++) {
|
||||||
diag.authAttempts = authAttempt;
|
diag.authAttempts = authAttempt;
|
||||||
|
|
@ -559,6 +652,13 @@ export class CliInstallerService {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async install(): Promise<void> {
|
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) {
|
if (this.installing) {
|
||||||
this.sendProgress({ type: 'error', error: 'Installation already in progress' });
|
this.sendProgress({ type: 'error', error: 'Installation already in progress' });
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -204,6 +204,7 @@ export interface GeneralConfig {
|
||||||
showDockIcon: boolean;
|
showDockIcon: boolean;
|
||||||
theme: 'dark' | 'light' | 'system';
|
theme: 'dark' | 'light' | 'system';
|
||||||
defaultTab: 'dashboard' | 'last-session';
|
defaultTab: 'dashboard' | 'last-session';
|
||||||
|
multimodelEnabled: boolean;
|
||||||
claudeRootPath: string | null;
|
claudeRootPath: string | null;
|
||||||
agentLanguage: string;
|
agentLanguage: string;
|
||||||
autoExpandAIGroups: boolean;
|
autoExpandAIGroups: boolean;
|
||||||
|
|
@ -290,6 +291,7 @@ const DEFAULT_CONFIG: AppConfig = {
|
||||||
showDockIcon: true,
|
showDockIcon: true,
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
defaultTab: 'dashboard',
|
defaultTab: 'dashboard',
|
||||||
|
multimodelEnabled: true,
|
||||||
claudeRootPath: null,
|
claudeRootPath: null,
|
||||||
agentLanguage: 'system',
|
agentLanguage: 'system',
|
||||||
autoExpandAIGroups: false,
|
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 { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
|
import { applyProviderRuntimeEnv } from '../runtime/providerRuntimeEnv';
|
||||||
import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver';
|
import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver';
|
||||||
|
|
||||||
import type { ScheduleLaunchConfig, ScheduleRun } from '@shared/types';
|
import type { ScheduleLaunchConfig, ScheduleRun } from '@shared/types';
|
||||||
|
|
@ -101,11 +102,16 @@ export class ScheduledTaskExecutor {
|
||||||
|
|
||||||
logger.info(`[${request.runId}] Spawning: ${binaryPath} ${args.join(' ')}`);
|
logger.info(`[${request.runId}] Spawning: ${binaryPath} ${args.join(' ')}`);
|
||||||
|
|
||||||
|
const env = applyProviderRuntimeEnv(
|
||||||
|
{ ...buildEnrichedEnv(binaryPath), ...shellEnv, CLAUDECODE: undefined },
|
||||||
|
request.config.providerId
|
||||||
|
);
|
||||||
|
|
||||||
const child = spawnCli(binaryPath, args, {
|
const child = spawnCli(binaryPath, args, {
|
||||||
cwd: request.config.cwd,
|
cwd: request.config.cwd,
|
||||||
// shellEnv spread after buildEnrichedEnv ensures freshly-resolved values
|
// shellEnv spread after buildEnrichedEnv ensures freshly-resolved values
|
||||||
// take precedence over the cached snapshot inside buildEnrichedEnv.
|
// take precedence over the cached snapshot inside buildEnrichedEnv.
|
||||||
env: { ...buildEnrichedEnv(binaryPath), ...shellEnv, CLAUDECODE: undefined },
|
env,
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import { getShellPreferredHome, resolveInteractiveShellEnv } from '@main/utils/s
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { getConfiguredCliFlavor } from './cliFlavor';
|
||||||
|
|
||||||
async function isExecutable(filePath: string): Promise<boolean> {
|
async function isExecutable(filePath: string): Promise<boolean> {
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
try {
|
try {
|
||||||
|
|
@ -165,6 +167,28 @@ async function resolveFromExplicitPath(inputPath: string): Promise<string | null
|
||||||
return 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;
|
let cachedPath: string | null | undefined;
|
||||||
|
|
||||||
/** Timestamp of last successful cache verification (ms). */
|
/** Timestamp of last successful cache verification (ms). */
|
||||||
|
|
@ -213,6 +237,7 @@ export class ClaudeBinaryResolver {
|
||||||
private static async runResolve(): Promise<string | null> {
|
private static async runResolve(): Promise<string | null> {
|
||||||
await resolveInteractiveShellEnv();
|
await resolveInteractiveShellEnv();
|
||||||
const enrichedPath = buildMergedCliPath(null);
|
const enrichedPath = buildMergedCliPath(null);
|
||||||
|
const flavor = getConfiguredCliFlavor();
|
||||||
|
|
||||||
const overrideRaw = process.env.CLAUDE_CLI_PATH?.trim();
|
const overrideRaw = process.env.CLAUDE_CLI_PATH?.trim();
|
||||||
if (overrideRaw) {
|
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 baseBinaryName = 'claude';
|
||||||
const fromPath = await resolveFromPathEnv(baseBinaryName, enrichedPath);
|
const fromPath = await resolveFromPathEnv(baseBinaryName, enrichedPath);
|
||||||
if (fromPath) {
|
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) => {
|
deleteDraft: async (teamName: string) => {
|
||||||
return invokeIpcWithResult<void>(TEAM_DELETE_DRAFT, teamName);
|
return invokeIpcWithResult<void>(TEAM_DELETE_DRAFT, teamName);
|
||||||
},
|
},
|
||||||
prepareProvisioning: async (cwd?: string) => {
|
prepareProvisioning: async (
|
||||||
return invokeIpcWithResult<TeamProvisioningPrepareResult>(TEAM_PREPARE_PROVISIONING, cwd);
|
cwd?: string,
|
||||||
|
providerId?: TeamLaunchRequest['providerId'],
|
||||||
|
providerIds?: TeamLaunchRequest['providerId'][]
|
||||||
|
) => {
|
||||||
|
return invokeIpcWithResult<TeamProvisioningPrepareResult>(
|
||||||
|
TEAM_PREPARE_PROVISIONING,
|
||||||
|
cwd,
|
||||||
|
providerId,
|
||||||
|
providerIds
|
||||||
|
);
|
||||||
},
|
},
|
||||||
createTeam: async (request: TeamCreateRequest) => {
|
createTeam: async (request: TeamCreateRequest) => {
|
||||||
return invokeIpcWithResult<TeamCreateResponse>(TEAM_CREATE, request);
|
return invokeIpcWithResult<TeamCreateResponse>(TEAM_CREATE, request);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { api, isElectronMode } from '@renderer/api';
|
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 { TerminalLogPanel } from '@renderer/components/terminal/TerminalLogPanel';
|
||||||
import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
|
import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
|
||||||
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
|
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
|
||||||
|
|
@ -24,11 +26,14 @@ import {
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
Loader2,
|
Loader2,
|
||||||
LogIn,
|
LogIn,
|
||||||
|
LogOut,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Terminal,
|
Terminal,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import type { CliInstallationStatus, CliProviderId, CliProviderStatus } from '@shared/types';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Border color by state
|
// Border color by state
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -141,7 +146,7 @@ const CliCheckingSpinner = ({
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
<span className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
Checking Claude CLI...
|
Checking AI Providers...
|
||||||
</span>
|
</span>
|
||||||
{showHint && (
|
{showHint && (
|
||||||
<p className="mt-0.5 text-xs" style={{ color: 'var(--color-text-muted)', opacity: 0.7 }}>
|
<p className="mt-0.5 text-xs" style={{ color: 'var(--color-text-muted)', opacity: 0.7 }}>
|
||||||
|
|
@ -162,22 +167,211 @@ interface InstalledBannerProps {
|
||||||
cliStatusLoading: boolean;
|
cliStatusLoading: boolean;
|
||||||
cliStatusError: string | null;
|
cliStatusError: string | null;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
|
multimodelEnabled: boolean;
|
||||||
|
multimodelBusy: boolean;
|
||||||
onInstall: () => void;
|
onInstall: () => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
|
onMultimodelToggle: (enabled: boolean) => void;
|
||||||
|
onProviderLogin: (providerId: CliProviderId) => void;
|
||||||
|
onProviderLogout: (providerId: CliProviderId) => void;
|
||||||
variant: BannerVariant;
|
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 = ({
|
const InstalledBanner = ({
|
||||||
cliStatus,
|
cliStatus,
|
||||||
cliStatusLoading,
|
cliStatusLoading,
|
||||||
cliStatusError,
|
cliStatusError,
|
||||||
isBusy,
|
isBusy,
|
||||||
|
multimodelEnabled,
|
||||||
|
multimodelBusy,
|
||||||
onInstall,
|
onInstall,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
|
onMultimodelToggle,
|
||||||
|
onProviderLogin,
|
||||||
|
onProviderLogout,
|
||||||
variant,
|
variant,
|
||||||
}: InstalledBannerProps): React.JSX.Element => {
|
}: InstalledBannerProps): React.JSX.Element => {
|
||||||
const openExtensionsTab = useStore((s) => s.openExtensionsTab);
|
const openExtensionsTab = useStore((s) => s.openExtensionsTab);
|
||||||
const styles = VARIANT_STYLES[variant];
|
const styles = VARIANT_STYLES[variant];
|
||||||
|
const runtimeLabel = formatRuntimeLabel(cliStatus);
|
||||||
|
const runtimeAuthSummary = formatRuntimeAuthSummary(cliStatus);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -189,12 +383,14 @@ const InstalledBanner = ({
|
||||||
<Terminal className="size-4 shrink-0" style={{ color: 'var(--color-text-muted)' }} />
|
<Terminal className="size-4 shrink-0" style={{ color: 'var(--color-text-muted)' }} />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm" style={{ color: 'var(--color-text)' }}>
|
{runtimeLabel && (
|
||||||
Claude CLI v{cliStatus.installedVersion ?? 'unknown'}
|
<span className="text-sm" style={{ color: 'var(--color-text)' }}>
|
||||||
</span>
|
{runtimeLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Update / Check for Updates — inline next to version */}
|
{/* Update / Check for Updates — inline next to version */}
|
||||||
{cliStatus.updateAvailable ? (
|
{cliStatus.supportsSelfUpdate && cliStatus.updateAvailable ? (
|
||||||
<button
|
<button
|
||||||
onClick={onInstall}
|
onClick={onInstall}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
|
|
@ -204,7 +400,7 @@ const InstalledBanner = ({
|
||||||
<Download className="size-3" />
|
<Download className="size-3" />
|
||||||
Update to v{cliStatus.latestVersion}
|
Update to v{cliStatus.latestVersion}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : cliStatus.supportsSelfUpdate ? (
|
||||||
<button
|
<button
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
disabled={cliStatusLoading}
|
disabled={cliStatusLoading}
|
||||||
|
|
@ -214,15 +410,15 @@ const InstalledBanner = ({
|
||||||
<RefreshCw className={cliStatusLoading ? 'size-3 animate-spin' : 'size-3'} />
|
<RefreshCw className={cliStatusLoading ? 'size-3 animate-spin' : 'size-3'} />
|
||||||
{cliStatusLoading ? 'Checking...' : 'Check for Updates'}
|
{cliStatusLoading ? 'Checking...' : 'Check for Updates'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{cliStatus.authLoggedIn && (
|
{runtimeAuthSummary && (
|
||||||
<span className="text-xs" style={{ color: '#4ade80' }}>
|
<span className="text-xs" style={{ color: '#4ade80' }}>
|
||||||
Authenticated
|
{runtimeAuthSummary}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{cliStatus.binaryPath && (
|
{cliStatus.showBinaryPath && cliStatus.binaryPath && (
|
||||||
<button
|
<button
|
||||||
className="truncate font-mono text-xs hover:underline"
|
className="truncate font-mono text-xs hover:underline"
|
||||||
style={{ color: 'var(--color-text-muted)' }}
|
style={{ color: 'var(--color-text-muted)' }}
|
||||||
|
|
@ -235,23 +431,146 @@ const InstalledBanner = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Extensions button — only when installed + authenticated */}
|
<div className="flex shrink-0 items-center gap-8">
|
||||||
{cliStatus.authLoggedIn && (
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<span className="text-xs font-medium" style={{ color: 'var(--color-text-secondary)' }}>
|
||||||
onClick={openExtensionsTab}
|
Multimodel
|
||||||
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"
|
</span>
|
||||||
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
|
{multimodelEnabled && (
|
||||||
>
|
<span
|
||||||
<Puzzle className="size-3.5" />
|
className="rounded-full border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em]"
|
||||||
Extensions
|
style={{
|
||||||
</button>
|
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>
|
</div>
|
||||||
{cliStatusError && !cliStatusLoading && (
|
{cliStatusError && !cliStatusLoading && (
|
||||||
<p className="mt-2 text-xs" style={{ color: '#f87171' }}>
|
<p className="mt-2 text-xs" style={{ color: '#f87171' }}>
|
||||||
Failed to check for updates. Check your network connection and try again.
|
Failed to check for updates. Check your network connection and try again.
|
||||||
</p>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -262,6 +581,8 @@ const InstalledBanner = ({
|
||||||
|
|
||||||
export const CliStatusBanner = (): React.JSX.Element | null => {
|
export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
const isElectron = useMemo(() => isElectronMode(), []);
|
const isElectron = useMemo(() => isElectronMode(), []);
|
||||||
|
const appConfig = useStore((s) => s.appConfig);
|
||||||
|
const updateConfig = useStore((s) => s.updateConfig);
|
||||||
const {
|
const {
|
||||||
cliStatus,
|
cliStatus,
|
||||||
cliStatusLoading,
|
cliStatusLoading,
|
||||||
|
|
@ -281,8 +602,14 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
} = useCliInstaller();
|
} = useCliInstaller();
|
||||||
|
|
||||||
const [showLoginTerminal, setShowLoginTerminal] = useState(false);
|
const [showLoginTerminal, setShowLoginTerminal] = useState(false);
|
||||||
|
const [providerTerminal, setProviderTerminal] = useState<{
|
||||||
|
providerId: CliProviderId;
|
||||||
|
action: 'login' | 'logout';
|
||||||
|
} | null>(null);
|
||||||
const [isVerifyingAuth, setIsVerifyingAuth] = useState(false);
|
const [isVerifyingAuth, setIsVerifyingAuth] = useState(false);
|
||||||
|
const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false);
|
||||||
const [showTroubleshoot, setShowTroubleshoot] = useState(false);
|
const [showTroubleshoot, setShowTroubleshoot] = useState(false);
|
||||||
|
const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isElectron) return;
|
if (!isElectron) return;
|
||||||
|
|
@ -312,6 +639,54 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
void fetchCliStatus();
|
void fetchCliStatus();
|
||||||
}, [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;
|
if (!isElectron) return null;
|
||||||
|
|
||||||
// Determine variant for styling
|
// Determine variant for styling
|
||||||
|
|
@ -328,6 +703,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
|
|
||||||
const variant = getVariant();
|
const variant = getVariant();
|
||||||
const styles = VARIANT_STYLES[variant];
|
const styles = VARIANT_STYLES[variant];
|
||||||
|
const providerTerminalCommand = providerTerminal
|
||||||
|
? providerTerminal.action === 'login'
|
||||||
|
? getProviderTerminalCommand(providerTerminal.providerId)
|
||||||
|
: getProviderTerminalLogoutCommand(providerTerminal.providerId)
|
||||||
|
: null;
|
||||||
|
|
||||||
// ── Loading / fetch error state ────────────────────────────────────────
|
// ── Loading / fetch error state ────────────────────────────────────────
|
||||||
if (!cliStatus && installerState === 'idle') {
|
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} />;
|
return <CliCheckingSpinner styles={styles} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -516,22 +916,28 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
{cliStatus.supportsSelfUpdate ? (
|
||||||
onClick={handleInstall}
|
<button
|
||||||
disabled={isBusy}
|
onClick={handleInstall}
|
||||||
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"
|
disabled={isBusy}
|
||||||
style={{ backgroundColor: '#3b82f6' }}
|
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
|
<Download className="size-4" />
|
||||||
</button>
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Installed but not logged in — yellow warning banner
|
// Installed but not logged in — yellow warning banner
|
||||||
if (cliStatus.installed && !cliStatus.authLoggedIn) {
|
if (cliStatus.installed && cliStatus.flavor !== 'free-code' && !cliStatus.authLoggedIn) {
|
||||||
if (isVerifyingAuth) {
|
if (isVerifyingAuth) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -565,8 +971,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
Not logged in
|
Not logged in
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
<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
|
{cliStatus.displayName} is installed but you are not authenticated. Login is
|
||||||
provisioning and AI features.
|
required for team provisioning and AI features.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -642,28 +1048,30 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
<li>
|
<li>
|
||||||
Open your terminal and run:{' '}
|
Open your terminal and run:{' '}
|
||||||
<code className="rounded bg-white/5 px-1.5 py-0.5 font-mono text-[11px]">
|
<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>{' '}
|
</code>{' '}
|
||||||
— check if it shows "Logged in"
|
— check if it shows "Logged in"
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
If it says logged in but the app doesn't see it, try:{' '}
|
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]">
|
<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>{' '}
|
</code>{' '}
|
||||||
then{' '}
|
then{' '}
|
||||||
<code className="rounded bg-white/5 px-1.5 py-0.5 font-mono text-[11px]">
|
<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>{' '}
|
</code>{' '}
|
||||||
again
|
again
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Make sure{' '}
|
Make sure the CLI in your terminal is the same runtime the app uses
|
||||||
<code className="rounded bg-white/5 px-1.5 py-0.5 font-mono text-[11px]">
|
{cliStatus.showBinaryPath && cliStatus.binaryPath && (
|
||||||
claude
|
|
||||||
</code>{' '}
|
|
||||||
in your terminal is the same binary the app uses
|
|
||||||
{cliStatus.binaryPath && (
|
|
||||||
<span>
|
<span>
|
||||||
:{' '}
|
:{' '}
|
||||||
<code className="rounded bg-white/5 px-1.5 py-0.5 font-mono text-[11px]">
|
<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>
|
</div>
|
||||||
{showLoginTerminal && cliStatus.binaryPath && (
|
{showLoginTerminal && cliStatus.binaryPath && (
|
||||||
<TerminalModal
|
<TerminalModal
|
||||||
title="Claude Auth Login"
|
title={`${cliStatus.displayName} Login`}
|
||||||
command={cliStatus.binaryPath}
|
command={cliStatus.binaryPath}
|
||||||
args={['auth', 'login']}
|
args={['auth', 'login']}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
|
@ -719,14 +1127,45 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
|
|
||||||
// Installed — show version, path, update info
|
// Installed — show version, path, update info
|
||||||
return (
|
return (
|
||||||
<InstalledBanner
|
<>
|
||||||
cliStatus={cliStatus}
|
<InstalledBanner
|
||||||
cliStatusLoading={cliStatusLoading}
|
cliStatus={cliStatus}
|
||||||
cliStatusError={cliStatusError ?? null}
|
cliStatusLoading={cliStatusLoading}
|
||||||
isBusy={isBusy}
|
cliStatusError={cliStatusError ?? null}
|
||||||
onInstall={handleInstall}
|
isBusy={isBusy}
|
||||||
onRefresh={handleRefresh}
|
multimodelEnabled={multimodelEnabled}
|
||||||
variant={variant}
|
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;
|
showDockIcon: boolean;
|
||||||
theme: 'dark' | 'light' | 'system';
|
theme: 'dark' | 'light' | 'system';
|
||||||
defaultTab: 'dashboard' | 'last-session';
|
defaultTab: 'dashboard' | 'last-session';
|
||||||
|
multimodelEnabled: boolean;
|
||||||
claudeRootPath: string | null;
|
claudeRootPath: string | null;
|
||||||
agentLanguage: string;
|
agentLanguage: string;
|
||||||
autoExpandAIGroups: boolean;
|
autoExpandAIGroups: boolean;
|
||||||
|
|
@ -169,6 +170,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
|
||||||
showDockIcon: displayConfig?.general?.showDockIcon ?? true,
|
showDockIcon: displayConfig?.general?.showDockIcon ?? true,
|
||||||
theme: displayConfig?.general?.theme ?? 'dark',
|
theme: displayConfig?.general?.theme ?? 'dark',
|
||||||
defaultTab: displayConfig?.general?.defaultTab ?? 'dashboard',
|
defaultTab: displayConfig?.general?.defaultTab ?? 'dashboard',
|
||||||
|
multimodelEnabled: displayConfig?.general?.multimodelEnabled ?? true,
|
||||||
claudeRootPath: displayConfig?.general?.claudeRootPath ?? null,
|
claudeRootPath: displayConfig?.general?.claudeRootPath ?? null,
|
||||||
agentLanguage: displayConfig?.general?.agentLanguage ?? 'system',
|
agentLanguage: displayConfig?.general?.agentLanguage ?? 'system',
|
||||||
autoExpandAIGroups: displayConfig?.general?.autoExpandAIGroups ?? false,
|
autoExpandAIGroups: displayConfig?.general?.autoExpandAIGroups ?? false,
|
||||||
|
|
|
||||||
|
|
@ -314,6 +314,7 @@ export function useSettingsHandlers({
|
||||||
showDockIcon: true,
|
showDockIcon: true,
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
defaultTab: 'dashboard',
|
defaultTab: 'dashboard',
|
||||||
|
multimodelEnabled: true,
|
||||||
claudeRootPath: null,
|
claudeRootPath: null,
|
||||||
agentLanguage: 'system',
|
agentLanguage: 'system',
|
||||||
autoExpandAIGroups: false,
|
autoExpandAIGroups: false,
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,22 @@
|
||||||
* Shows detection status, version info, download progress, and error states.
|
* 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 { 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 { useCliInstaller } from '@renderer/hooks/useCliInstaller';
|
||||||
|
import { useStore } from '@renderer/store';
|
||||||
import { formatBytes } from '@renderer/utils/formatters';
|
import { formatBytes } from '@renderer/utils/formatters';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Download,
|
Download,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
LogIn,
|
||||||
|
LogOut,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Terminal,
|
Terminal,
|
||||||
|
|
@ -22,8 +28,132 @@ import {
|
||||||
|
|
||||||
import { SettingsSectionHeader } from '../components';
|
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 => {
|
export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
const isElectron = useMemo(() => isElectronMode(), []);
|
const isElectron = useMemo(() => isElectronMode(), []);
|
||||||
|
const appConfig = useStore((s) => s.appConfig);
|
||||||
|
const updateConfig = useStore((s) => s.updateConfig);
|
||||||
const {
|
const {
|
||||||
cliStatus,
|
cliStatus,
|
||||||
installerState,
|
installerState,
|
||||||
|
|
@ -36,7 +166,18 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
installCli,
|
installCli,
|
||||||
isBusy,
|
isBusy,
|
||||||
cliStatusLoading,
|
cliStatusLoading,
|
||||||
|
invalidateCliStatus,
|
||||||
} = useCliInstaller();
|
} = 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(() => {
|
useEffect(() => {
|
||||||
if (isElectron) {
|
if (isElectron) {
|
||||||
|
|
@ -52,38 +193,118 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
void fetchCliStatus();
|
void fetchCliStatus();
|
||||||
}, [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;
|
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 (
|
return (
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<SettingsSectionHeader title="Claude CLI" />
|
<SettingsSectionHeader title="CLI Runtime" />
|
||||||
<div className="space-y-3 py-2">
|
<div className="space-y-3 py-2">
|
||||||
{/* Loading status */}
|
{/* Loading status */}
|
||||||
{!cliStatus && installerState === 'idle' && (
|
{!effectiveCliStatus && installerState === 'idle' && (
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2 text-sm"
|
className="flex items-center gap-2 text-sm"
|
||||||
style={{ color: 'var(--color-text-muted)' }}
|
style={{ color: 'var(--color-text-muted)' }}
|
||||||
>
|
>
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
Checking CLI...
|
Checking AI Providers...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Status display */}
|
{/* Status display */}
|
||||||
{cliStatus && installerState === 'idle' && (
|
{effectiveCliStatus && installerState === 'idle' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{cliStatus.installed ? (
|
{effectiveCliStatus.installed ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<Terminal
|
<Terminal
|
||||||
className="size-4 shrink-0"
|
className="size-4 shrink-0"
|
||||||
style={{ color: 'var(--color-text-muted)' }}
|
style={{ color: 'var(--color-text-muted)' }}
|
||||||
/>
|
/>
|
||||||
<span style={{ color: 'var(--color-text)' }}>
|
{runtimeLabel && (
|
||||||
Claude CLI v{cliStatus.installedVersion ?? 'unknown'}
|
<span style={{ color: 'var(--color-text)' }}>{runtimeLabel}</span>
|
||||||
</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 */}
|
{/* Inline action buttons */}
|
||||||
{cliStatus.updateAvailable ? (
|
{effectiveCliStatus.supportsSelfUpdate && effectiveCliStatus.updateAvailable ? (
|
||||||
<button
|
<button
|
||||||
onClick={handleInstall}
|
onClick={handleInstall}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
|
|
@ -93,7 +314,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
<Download className="size-3.5" />
|
<Download className="size-3.5" />
|
||||||
Update
|
Update
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : effectiveCliStatus.supportsSelfUpdate ? (
|
||||||
<button
|
<button
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
disabled={cliStatusLoading}
|
disabled={cliStatusLoading}
|
||||||
|
|
@ -115,7 +336,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
) : null}
|
||||||
{/* Extensions button — right-aligned */}
|
{/* Extensions button — right-aligned */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -130,20 +351,116 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
Extensions
|
Extensions
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{cliStatus.binaryPath && (
|
{effectiveCliStatus.showBinaryPath && effectiveCliStatus.binaryPath && (
|
||||||
<p
|
<p
|
||||||
className="ml-6 truncate text-xs"
|
className="ml-6 truncate text-xs"
|
||||||
style={{ color: 'var(--color-text-muted)' }}
|
style={{ color: 'var(--color-text-muted)' }}
|
||||||
title={cliStatus.binaryPath}
|
title={effectiveCliStatus.binaryPath}
|
||||||
>
|
>
|
||||||
{cliStatus.binaryPath}
|
{effectiveCliStatus.binaryPath}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{cliStatus.updateAvailable && cliStatus.latestVersion && (
|
{effectiveCliStatus.supportsSelfUpdate &&
|
||||||
<div className="ml-6 flex items-center gap-2">
|
effectiveCliStatus.updateAvailable &&
|
||||||
<span className="text-xs" style={{ color: '#60a5fa' }}>
|
effectiveCliStatus.latestVersion && (
|
||||||
v{cliStatus.installedVersion} → v{cliStatus.latestVersion}
|
<div className="ml-6 flex items-center gap-2">
|
||||||
</span>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -158,7 +475,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Install button (CLI not installed) */}
|
{/* Install button (CLI not installed) */}
|
||||||
{!cliStatus.installed && (
|
{!effectiveCliStatus.installed && effectiveCliStatus.supportsSelfUpdate && (
|
||||||
<button
|
<button
|
||||||
onClick={handleInstall}
|
onClick={handleInstall}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
|
|
@ -169,6 +486,11 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
Install Claude CLI
|
Install Claude CLI
|
||||||
</button>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -270,6 +592,30 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ interface EmbeddedTerminalProps {
|
||||||
args?: string[];
|
args?: string[];
|
||||||
/** Working directory */
|
/** Working directory */
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
|
/** Environment variables merged into the PTY process env */
|
||||||
|
env?: Record<string, string>;
|
||||||
/** Callback when PTY process exits */
|
/** Callback when PTY process exits */
|
||||||
onExit?: (exitCode: number) => void;
|
onExit?: (exitCode: number) => void;
|
||||||
/** CSS class for container */
|
/** CSS class for container */
|
||||||
|
|
@ -26,6 +28,7 @@ export const EmbeddedTerminal = ({
|
||||||
command,
|
command,
|
||||||
args,
|
args,
|
||||||
cwd,
|
cwd,
|
||||||
|
env,
|
||||||
onExit,
|
onExit,
|
||||||
className,
|
className,
|
||||||
}: EmbeddedTerminalProps): React.JSX.Element => {
|
}: EmbeddedTerminalProps): React.JSX.Element => {
|
||||||
|
|
@ -99,6 +102,7 @@ export const EmbeddedTerminal = ({
|
||||||
...(command ? { command } : {}),
|
...(command ? { command } : {}),
|
||||||
...(args ? { args } : {}),
|
...(args ? { args } : {}),
|
||||||
...(cwd ? { cwd } : {}),
|
...(cwd ? { cwd } : {}),
|
||||||
|
...(env ? { env } : {}),
|
||||||
cols: term.cols,
|
cols: term.cols,
|
||||||
rows: term.rows,
|
rows: term.rows,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ interface TerminalModalProps {
|
||||||
args?: string[];
|
args?: string[];
|
||||||
/** Working directory */
|
/** Working directory */
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
|
/** Environment variables merged into the PTY process env */
|
||||||
|
env?: Record<string, string>;
|
||||||
/** Called when the modal should close */
|
/** Called when the modal should close */
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
/** Called when the PTY process exits */
|
/** Called when the PTY process exits */
|
||||||
|
|
@ -31,6 +33,7 @@ export function TerminalModal({
|
||||||
command,
|
command,
|
||||||
args,
|
args,
|
||||||
cwd,
|
cwd,
|
||||||
|
env,
|
||||||
onClose,
|
onClose,
|
||||||
onExit,
|
onExit,
|
||||||
autoCloseOnSuccessMs = 0,
|
autoCloseOnSuccessMs = 0,
|
||||||
|
|
@ -116,7 +119,7 @@ export function TerminalModal({
|
||||||
|
|
||||||
{/* Terminal area — always visible, status bar overlaid at bottom */}
|
{/* Terminal area — always visible, status bar overlaid at bottom */}
|
||||||
<div className="relative flex min-h-0 flex-1 flex-col p-2">
|
<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 && (
|
{exited !== null && (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -1167,6 +1167,11 @@ export function initializeNotificationListeners(): () => void {
|
||||||
cliInstallerError: progress.error ?? 'Unknown error',
|
cliInstallerError: progress.error ?? 'Unknown error',
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case 'status':
|
||||||
|
if (progress.status) {
|
||||||
|
useStore.setState({ cliStatus: progress.status });
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (typeof cleanup === 'function') {
|
if (typeof cleanup === 'function') {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,40 @@ export type CliPlatform =
|
||||||
| 'win32-x64'
|
| 'win32-x64'
|
||||||
| 'win32-arm64';
|
| '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
|
// Installation Status
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -29,6 +63,16 @@ export type CliPlatform =
|
||||||
* Current CLI installation status returned by getStatus().
|
* Current CLI installation status returned by getStatus().
|
||||||
*/
|
*/
|
||||||
export interface CliInstallationStatus {
|
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 */
|
/** Whether CLI binary is found on the system */
|
||||||
installed: boolean;
|
installed: boolean;
|
||||||
/** Installed version string (e.g. "2.1.59"), null if not installed */
|
/** Installed version string (e.g. "2.1.59"), null if not installed */
|
||||||
|
|
@ -43,6 +87,8 @@ export interface CliInstallationStatus {
|
||||||
authLoggedIn: boolean;
|
authLoggedIn: boolean;
|
||||||
/** Auth method if logged in (e.g. "oauth_token", "api_key"), null otherwise */
|
/** Auth method if logged in (e.g. "oauth_token", "api_key"), null otherwise */
|
||||||
authMethod: string | null;
|
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 {
|
export interface CliInstallerProgress {
|
||||||
/** Current phase of the installation process */
|
/** 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' */
|
/** Download progress 0-100, only present for 'downloading' */
|
||||||
percent?: number;
|
percent?: number;
|
||||||
/** Bytes downloaded so far */
|
/** Bytes downloaded so far */
|
||||||
|
|
@ -69,6 +115,8 @@ export interface CliInstallerProgress {
|
||||||
detail?: string;
|
detail?: string;
|
||||||
/** Raw terminal output chunk (with ANSI codes), only for 'installing' */
|
/** Raw terminal output chunk (with ANSI codes), only for 'installing' */
|
||||||
rawChunk?: string;
|
rawChunk?: string;
|
||||||
|
/** Partial or full CLI status snapshot during status gathering. */
|
||||||
|
status?: CliInstallationStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -308,6 +308,8 @@ export interface AppConfig {
|
||||||
theme: 'dark' | 'light' | 'system';
|
theme: 'dark' | 'light' | 'system';
|
||||||
/** Default tab to show on app launch */
|
/** Default tab to show on app launch */
|
||||||
defaultTab: 'dashboard' | 'last-session';
|
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) */
|
/** Optional custom Claude root folder (auto-detected when null) */
|
||||||
claudeRootPath: string | null;
|
claudeRootPath: string | null;
|
||||||
/** Agent communication language ('system' = use OS locale) */
|
/** 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', () => {
|
describe('fetchCliStatus', () => {
|
||||||
it('updates cliStatus from API', async () => {
|
it('updates cliStatus from API', async () => {
|
||||||
const mockStatus: CliInstallationStatus = {
|
const mockStatus: CliInstallationStatus = {
|
||||||
|
flavor: 'claude',
|
||||||
|
displayName: 'Claude CLI',
|
||||||
|
supportsSelfUpdate: true,
|
||||||
|
showVersionDetails: true,
|
||||||
|
showBinaryPath: true,
|
||||||
installed: true,
|
installed: true,
|
||||||
installedVersion: '2.1.59',
|
installedVersion: '2.1.59',
|
||||||
binaryPath: '/usr/local/bin/claude',
|
binaryPath: '/usr/local/bin/claude',
|
||||||
|
|
@ -90,6 +95,7 @@ describe('cliInstallerSlice', () => {
|
||||||
updateAvailable: false,
|
updateAvailable: false,
|
||||||
authLoggedIn: false,
|
authLoggedIn: false,
|
||||||
authMethod: null,
|
authMethod: null,
|
||||||
|
providers: [],
|
||||||
};
|
};
|
||||||
vi.mocked(api.cliInstaller.getStatus).mockResolvedValue(mockStatus);
|
vi.mocked(api.cliInstaller.getStatus).mockResolvedValue(mockStatus);
|
||||||
|
|
||||||
|
|
@ -109,6 +115,11 @@ describe('cliInstallerSlice', () => {
|
||||||
|
|
||||||
it('detects update available', async () => {
|
it('detects update available', async () => {
|
||||||
const mockStatus: CliInstallationStatus = {
|
const mockStatus: CliInstallationStatus = {
|
||||||
|
flavor: 'claude',
|
||||||
|
displayName: 'Claude CLI',
|
||||||
|
supportsSelfUpdate: true,
|
||||||
|
showVersionDetails: true,
|
||||||
|
showBinaryPath: true,
|
||||||
installed: true,
|
installed: true,
|
||||||
installedVersion: '2.1.34',
|
installedVersion: '2.1.34',
|
||||||
binaryPath: '/usr/local/bin/claude',
|
binaryPath: '/usr/local/bin/claude',
|
||||||
|
|
@ -116,6 +127,7 @@ describe('cliInstallerSlice', () => {
|
||||||
updateAvailable: true,
|
updateAvailable: true,
|
||||||
authLoggedIn: true,
|
authLoggedIn: true,
|
||||||
authMethod: 'oauth_token',
|
authMethod: 'oauth_token',
|
||||||
|
providers: [],
|
||||||
};
|
};
|
||||||
vi.mocked(api.cliInstaller.getStatus).mockResolvedValue(mockStatus);
|
vi.mocked(api.cliInstaller.getStatus).mockResolvedValue(mockStatus);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue