mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
feat(cli-settings): support multi-provider settings for Claude, Codex, and Gemini
Decouple CLI settings architecture from Claude-only to support multiple
providers. Each provider has independent settings UI and backend handling.
- Add CliProvider type discriminator ('claude' | 'codex' | 'gemini')
- Add CodexCliSettings (profile, authJson, configToml) and GeminiCliSettings types
- Update EndpointSettings with provider field (defaults 'claude' for backward compat)
- Refactor CliSettingsModal with provider selector and provider-specific forms
- Remove includeCoAuthoredBy field across all layers
- Extend CliConfigModal to show Config Profile for all tools (not just claude)
- Add provider-aware argument injection in cli-session-manager (--settings/--profile/env)
- Rename addClaudeCustomEndpoint to addCustomEndpoint (old name kept as deprecated alias)
- Replace providerBasedCount/directCount with per-provider counts in useCliSettings hook
- Update CliSettingsList with provider badges and per-provider stat cards
- Add Codex and Gemini test cases for validateSettings and createDefaultSettings
This commit is contained in:
@@ -63,23 +63,18 @@ function CliSettingsCard({
|
||||
}: CliSettingsCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
// Determine mode based on settings
|
||||
const isProviderBased = Boolean(
|
||||
cliSettings.settings.env.ANTHROPIC_BASE_URL &&
|
||||
!cliSettings.settings.env.ANTHROPIC_BASE_URL.includes('api.anthropic.com')
|
||||
);
|
||||
|
||||
const getModeBadge = () => {
|
||||
if (isProviderBased) {
|
||||
return (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{formatMessage({ id: 'apiSettings.cliSettings.providerBased' })}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
// Display provider badge
|
||||
const getProviderBadge = () => {
|
||||
const provider = cliSettings.provider || 'claude';
|
||||
const variants: Record<string, { variant: 'secondary' | 'outline' | 'default'; label: string }> = {
|
||||
claude: { variant: 'secondary', label: 'Claude' },
|
||||
codex: { variant: 'outline', label: 'Codex' },
|
||||
gemini: { variant: 'default', label: 'Gemini' },
|
||||
};
|
||||
const config = variants[provider] || variants.claude;
|
||||
return (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{formatMessage({ id: 'apiSettings.cliSettings.direct' })}
|
||||
<Badge variant={config.variant} className="text-xs">
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
@@ -91,6 +86,12 @@ function CliSettingsCard({
|
||||
return <Badge variant="success">{formatMessage({ id: 'apiSettings.common.enabled' })}</Badge>;
|
||||
};
|
||||
|
||||
// Get provider-appropriate endpoint URL for display
|
||||
const endpointUrl = (() => {
|
||||
const env = cliSettings.settings.env;
|
||||
return env.ANTHROPIC_BASE_URL || env.OPENAI_BASE_URL || '';
|
||||
})();
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
@@ -99,7 +100,7 @@ function CliSettingsCard({
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-lg font-semibold text-foreground truncate">{cliSettings.name}</h3>
|
||||
{getStatusBadge()}
|
||||
{getModeBadge()}
|
||||
{getProviderBadge()}
|
||||
</div>
|
||||
{cliSettings.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{cliSettings.description}</p>
|
||||
@@ -107,17 +108,12 @@ function CliSettingsCard({
|
||||
<div className="flex items-center gap-4 mt-2 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Settings className="w-3 h-3" />
|
||||
{cliSettings.settings.model || 'sonnet'}
|
||||
{cliSettings.settings.model || 'default'}
|
||||
</span>
|
||||
{cliSettings.settings.env.ANTHROPIC_BASE_URL && (
|
||||
<span className="flex items-center gap-1 truncate max-w-[200px]" title={cliSettings.settings.env.ANTHROPIC_BASE_URL}>
|
||||
{endpointUrl && (
|
||||
<span className="flex items-center gap-1 truncate max-w-[200px]" title={endpointUrl}>
|
||||
<LinkIcon className="w-3 h-3 flex-shrink-0" />
|
||||
{cliSettings.settings.env.ANTHROPIC_BASE_URL}
|
||||
</span>
|
||||
)}
|
||||
{cliSettings.settings.includeCoAuthoredBy !== undefined && (
|
||||
<span>
|
||||
{formatMessage({ id: 'apiSettings.cliSettings.coAuthoredBy' })}: {formatMessage({ id: cliSettings.settings.includeCoAuthoredBy ? 'common.yes' : 'common.no' })}
|
||||
{endpointUrl}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -168,8 +164,7 @@ export function CliSettingsList({
|
||||
cliSettings,
|
||||
totalCount,
|
||||
enabledCount,
|
||||
providerBasedCount,
|
||||
directCount,
|
||||
providerCounts,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useCliSettings();
|
||||
@@ -219,7 +214,7 @@ export function CliSettingsList({
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="w-5 h-5 text-primary" />
|
||||
@@ -240,21 +235,21 @@ export function CliSettingsList({
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<LinkIcon className="w-5 h-5 text-blue-500" />
|
||||
<span className="text-2xl font-bold">{providerBasedCount}</span>
|
||||
<span className="text-2xl font-bold">{providerCounts.claude || 0}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'apiSettings.cliSettings.providerBased' })}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">Claude</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="w-5 h-5 text-orange-500" />
|
||||
<span className="text-2xl font-bold">{directCount}</span>
|
||||
<span className="text-2xl font-bold">{providerCounts.codex || 0}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'apiSettings.cliSettings.direct' })}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">Codex</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl font-bold">{providerCounts.gemini || 0}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">Gemini</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,8 +26,10 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/Select';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup';
|
||||
import { useConfigStore, selectCliTools } from '@/stores/configStore';
|
||||
import { useCliSettings } from '@/hooks/useApiSettings';
|
||||
|
||||
export type CliTool = 'claude' | 'gemini' | 'qwen' | 'codex' | 'opencode';
|
||||
export type CliTool = string;
|
||||
export type LaunchMode = 'default' | 'yolo';
|
||||
export type ShellKind = 'bash' | 'pwsh' | 'cmd';
|
||||
|
||||
@@ -39,6 +41,8 @@ export interface CliSessionConfig {
|
||||
workingDir: string;
|
||||
/** Session tag for grouping (auto-generated if not provided) */
|
||||
tag: string;
|
||||
/** CLI Settings endpoint ID for custom API configuration */
|
||||
settingsEndpointId?: string;
|
||||
}
|
||||
|
||||
export interface CliConfigModalProps {
|
||||
@@ -48,23 +52,13 @@ export interface CliConfigModalProps {
|
||||
onCreateSession: (config: CliSessionConfig) => Promise<void>;
|
||||
}
|
||||
|
||||
const CLI_TOOLS: CliTool[] = ['claude', 'gemini', 'qwen', 'codex', 'opencode'];
|
||||
|
||||
const MODEL_OPTIONS: Record<CliTool, string[]> = {
|
||||
claude: ['sonnet', 'haiku'],
|
||||
gemini: ['gemini-2.5-pro', 'gemini-2.5-flash'],
|
||||
qwen: ['coder-model'],
|
||||
codex: ['gpt-5.2'],
|
||||
opencode: ['opencode/glm-4.7-free'],
|
||||
};
|
||||
|
||||
const AUTO_MODEL_VALUE = '__auto__';
|
||||
|
||||
/**
|
||||
* Generate a tag name: {tool}-{HHmmss}
|
||||
* Example: gemini-143052
|
||||
*/
|
||||
function generateTag(tool: CliTool): string {
|
||||
function generateTag(tool: string): string {
|
||||
const now = new Date();
|
||||
const time = now.toTimeString().slice(0, 8).replace(/:/g, '');
|
||||
return `${tool}-${time}`;
|
||||
@@ -78,8 +72,18 @@ export function CliConfigModal({
|
||||
}: CliConfigModalProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
// Dynamic tool data from configStore
|
||||
const cliTools = useConfigStore(selectCliTools);
|
||||
const enabledTools = React.useMemo(
|
||||
() =>
|
||||
Object.entries(cliTools)
|
||||
.filter(([, config]) => config.enabled)
|
||||
.map(([key]) => key),
|
||||
[cliTools]
|
||||
);
|
||||
|
||||
const [tool, setTool] = React.useState<CliTool>('gemini');
|
||||
const [model, setModel] = React.useState<string | undefined>(MODEL_OPTIONS.gemini[0]);
|
||||
const [model, setModel] = React.useState<string | undefined>(undefined);
|
||||
const [launchMode, setLaunchMode] = React.useState<LaunchMode>('yolo');
|
||||
// Default to 'cmd' on Windows for better compatibility with npm CLI tools (.cmd files)
|
||||
const [preferredShell, setPreferredShell] = React.useState<ShellKind>(
|
||||
@@ -91,7 +95,46 @@ export function CliConfigModal({
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const modelOptions = React.useMemo(() => MODEL_OPTIONS[tool] ?? [], [tool]);
|
||||
// CLI Settings integration (for all tools)
|
||||
const { cliSettings } = useCliSettings({ enabled: true });
|
||||
|
||||
// Map tool names to provider types for filtering
|
||||
const toolProviderMap: Record<string, string> = {
|
||||
claude: 'claude',
|
||||
codex: 'codex',
|
||||
gemini: 'gemini',
|
||||
};
|
||||
const currentProvider = toolProviderMap[tool] || tool;
|
||||
|
||||
const enabledCliSettings = React.useMemo(
|
||||
() => (cliSettings || []).filter((s) => s.enabled && (s.provider || 'claude') === currentProvider),
|
||||
[cliSettings, currentProvider]
|
||||
);
|
||||
const [settingsEndpointId, setSettingsEndpointId] = React.useState<string | undefined>(undefined);
|
||||
|
||||
// Reset settingsEndpointId when tool changes
|
||||
React.useEffect(() => {
|
||||
setSettingsEndpointId(undefined);
|
||||
}, [tool]);
|
||||
|
||||
// Derive model options from configStore + CLI Settings profile override
|
||||
const modelOptions = React.useMemo(() => {
|
||||
// If a CLI Settings profile is selected and has availableModels, use those
|
||||
if (settingsEndpointId) {
|
||||
const endpoint = enabledCliSettings.find((s) => s.id === settingsEndpointId);
|
||||
if (endpoint?.settings.availableModels?.length) {
|
||||
return endpoint.settings.availableModels;
|
||||
}
|
||||
}
|
||||
const toolConfig = cliTools[tool];
|
||||
if (!toolConfig) return [];
|
||||
if (toolConfig.availableModels?.length) return toolConfig.availableModels;
|
||||
const models = [toolConfig.primaryModel];
|
||||
if (toolConfig.secondaryModel && toolConfig.secondaryModel !== toolConfig.primaryModel) {
|
||||
models.push(toolConfig.secondaryModel);
|
||||
}
|
||||
return models;
|
||||
}, [cliTools, tool, settingsEndpointId, enabledCliSettings]);
|
||||
|
||||
// Generate new tag when modal opens or tool changes
|
||||
const regenerateTag = React.useCallback(() => {
|
||||
@@ -104,6 +147,7 @@ export function CliConfigModal({
|
||||
const nextWorkingDir = defaultWorkingDir ?? '';
|
||||
setWorkingDir(nextWorkingDir);
|
||||
setError(null);
|
||||
setSettingsEndpointId(undefined);
|
||||
regenerateTag();
|
||||
}, [isOpen, defaultWorkingDir, regenerateTag]);
|
||||
|
||||
@@ -113,12 +157,23 @@ export function CliConfigModal({
|
||||
const suffix = tag.split('-').pop() || '';
|
||||
setTag(`${tool}-${suffix}`);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only re-run when tool changes, reading tag intentionally stale
|
||||
}, [tool]);
|
||||
|
||||
// Sync initial model when tool/modelOptions change
|
||||
React.useEffect(() => {
|
||||
if (modelOptions.length > 0 && (!model || !modelOptions.includes(model))) {
|
||||
setModel(modelOptions[0]);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only re-run when modelOptions changes
|
||||
}, [modelOptions]);
|
||||
|
||||
const handleToolChange = (nextTool: string) => {
|
||||
const next = nextTool as CliTool;
|
||||
setTool(next);
|
||||
const nextModels = MODEL_OPTIONS[next] ?? [];
|
||||
setTool(nextTool as CliTool);
|
||||
const nextConfig = cliTools[nextTool];
|
||||
const nextModels = nextConfig?.availableModels?.length
|
||||
? nextConfig.availableModels
|
||||
: [nextConfig?.primaryModel, nextConfig?.secondaryModel].filter(Boolean) as string[];
|
||||
if (!model || !nextModels.includes(model)) {
|
||||
setModel(nextModels[0]);
|
||||
}
|
||||
@@ -148,6 +203,7 @@ export function CliConfigModal({
|
||||
preferredShell,
|
||||
workingDir: dir,
|
||||
tag: finalTag,
|
||||
settingsEndpointId,
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -209,7 +265,7 @@ export function CliConfigModal({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CLI_TOOLS.map((t) => (
|
||||
{enabledTools.map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
{t}
|
||||
</SelectItem>
|
||||
@@ -245,6 +301,45 @@ export function CliConfigModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Config Profile (all tools with settings) */}
|
||||
{enabledCliSettings.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cli-config-profile">
|
||||
{formatMessage({ id: 'terminalDashboard.cliConfig.configProfile', defaultMessage: 'Config Profile' })}
|
||||
</Label>
|
||||
<Select
|
||||
value={settingsEndpointId ?? '__default__'}
|
||||
onValueChange={(v) => {
|
||||
const id = v === '__default__' ? undefined : v;
|
||||
setSettingsEndpointId(id);
|
||||
// If profile has availableModels, use those for model dropdown
|
||||
if (id) {
|
||||
const endpoint = enabledCliSettings.find((s) => s.id === id);
|
||||
if (endpoint?.settings.availableModels?.length) {
|
||||
setModel(endpoint.settings.availableModels[0]);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger id="cli-config-profile">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">
|
||||
{formatMessage({ id: 'terminalDashboard.cliConfig.defaultProfile', defaultMessage: 'Default' })}
|
||||
</SelectItem>
|
||||
{enabledCliSettings.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'terminalDashboard.cliConfig.configProfileHint', defaultMessage: 'Select a CLI Settings profile for custom API configuration.' })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mode */}
|
||||
<div className="space-y-2">
|
||||
<Label>{formatMessage({ id: 'terminalDashboard.cliConfig.mode' })}</Label>
|
||||
|
||||
@@ -699,8 +699,8 @@ export interface UseCliSettingsReturn {
|
||||
cliSettings: CliSettingsEndpoint[];
|
||||
totalCount: number;
|
||||
enabledCount: number;
|
||||
providerBasedCount: number;
|
||||
directCount: number;
|
||||
/** Count per provider type */
|
||||
providerCounts: Record<string, number>;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
error: Error | null;
|
||||
@@ -723,13 +723,12 @@ export function useCliSettings(options: UseCliSettingsOptions = {}): UseCliSetti
|
||||
const cliSettings = query.data?.endpoints ?? [];
|
||||
const enabledCliSettings = cliSettings.filter((s) => s.enabled);
|
||||
|
||||
// Determine mode based on whether settings have providerId in description or env vars
|
||||
const providerBasedCount = cliSettings.filter((s) => {
|
||||
// Provider-based: has ANTHROPIC_BASE_URL set to provider's apiBase
|
||||
return s.settings.env.ANTHROPIC_BASE_URL && !s.settings.env.ANTHROPIC_BASE_URL.includes('api.anthropic.com');
|
||||
}).length;
|
||||
|
||||
const directCount = cliSettings.length - providerBasedCount;
|
||||
// Count settings per provider type
|
||||
const providerCounts = cliSettings.reduce<Record<string, number>>((acc, s) => {
|
||||
const provider = s.provider || 'claude';
|
||||
acc[provider] = (acc[provider] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const refetch = async () => {
|
||||
await query.refetch();
|
||||
@@ -743,8 +742,7 @@ export function useCliSettings(options: UseCliSettingsOptions = {}): UseCliSetti
|
||||
cliSettings,
|
||||
totalCount: cliSettings.length,
|
||||
enabledCount: enabledCliSettings.length,
|
||||
providerBasedCount,
|
||||
directCount,
|
||||
providerCounts,
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
error: query.error,
|
||||
|
||||
@@ -6020,23 +6020,50 @@ export async function uninstallCcwLitellm(): Promise<{ success: boolean; message
|
||||
* CLI Settings (Claude CLI endpoint configuration)
|
||||
* Maps to backend EndpointSettings from /api/cli/settings
|
||||
*/
|
||||
/**
|
||||
* CLI Provider type
|
||||
*/
|
||||
export type CliProvider = 'claude' | 'codex' | 'gemini';
|
||||
|
||||
/**
|
||||
* Base settings fields shared across all providers
|
||||
*/
|
||||
export interface CliSettingsBase {
|
||||
env: Record<string, string | undefined>;
|
||||
model?: string;
|
||||
tags?: string[];
|
||||
availableModels?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude-specific settings
|
||||
*/
|
||||
export interface ClaudeCliSettingsApi extends CliSettingsBase {
|
||||
settingsFile?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex-specific settings
|
||||
*/
|
||||
export interface CodexCliSettingsApi extends CliSettingsBase {
|
||||
profile?: string;
|
||||
authJson?: string;
|
||||
configToml?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini-specific settings
|
||||
*/
|
||||
export interface GeminiCliSettingsApi extends CliSettingsBase {
|
||||
}
|
||||
|
||||
export interface CliSettingsEndpoint {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
settings: {
|
||||
env: {
|
||||
ANTHROPIC_AUTH_TOKEN?: string;
|
||||
ANTHROPIC_BASE_URL?: string;
|
||||
DISABLE_AUTOUPDATER?: string;
|
||||
[key: string]: string | undefined;
|
||||
};
|
||||
model?: string;
|
||||
includeCoAuthoredBy?: boolean;
|
||||
settingsFile?: string;
|
||||
availableModels?: string[];
|
||||
tags?: string[];
|
||||
};
|
||||
/** CLI provider type (defaults to 'claude' for backward compat) */
|
||||
provider: CliProvider;
|
||||
settings: ClaudeCliSettingsApi | CodexCliSettingsApi | GeminiCliSettingsApi;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -6057,19 +6084,9 @@ export interface SaveCliSettingsRequest {
|
||||
id?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
settings: {
|
||||
env: {
|
||||
ANTHROPIC_AUTH_TOKEN?: string;
|
||||
ANTHROPIC_BASE_URL?: string;
|
||||
DISABLE_AUTOUPDATER?: string;
|
||||
[key: string]: string | undefined;
|
||||
};
|
||||
model?: string;
|
||||
includeCoAuthoredBy?: boolean;
|
||||
settingsFile?: string;
|
||||
availableModels?: string[];
|
||||
tags?: string[];
|
||||
};
|
||||
/** CLI provider type */
|
||||
provider?: CliProvider;
|
||||
settings: ClaudeCliSettingsApi | CodexCliSettingsApi | GeminiCliSettingsApi;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -6565,6 +6582,8 @@ export interface CreateCliSessionInput {
|
||||
resumeKey?: string;
|
||||
/** Launch mode for native CLI sessions (default or yolo). */
|
||||
launchMode?: 'default' | 'yolo';
|
||||
/** Settings endpoint ID for injecting env vars and settings into CLI process. */
|
||||
settingsEndpointId?: string;
|
||||
}
|
||||
|
||||
function withPath(url: string, projectPath?: string): string {
|
||||
|
||||
Reference in New Issue
Block a user