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:
catlog22
2026-02-25 17:40:43 +08:00
parent c11596c038
commit d6acbaf30f
11 changed files with 927 additions and 497 deletions

View File

@@ -63,23 +63,18 @@ function CliSettingsCard({
}: CliSettingsCardProps) { }: CliSettingsCardProps) {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
// Determine mode based on settings // Display provider badge
const isProviderBased = Boolean( const getProviderBadge = () => {
cliSettings.settings.env.ANTHROPIC_BASE_URL && const provider = cliSettings.provider || 'claude';
!cliSettings.settings.env.ANTHROPIC_BASE_URL.includes('api.anthropic.com') const variants: Record<string, { variant: 'secondary' | 'outline' | 'default'; label: string }> = {
); claude: { variant: 'secondary', label: 'Claude' },
codex: { variant: 'outline', label: 'Codex' },
const getModeBadge = () => { gemini: { variant: 'default', label: 'Gemini' },
if (isProviderBased) { };
const config = variants[provider] || variants.claude;
return ( return (
<Badge variant="secondary" className="text-xs"> <Badge variant={config.variant} className="text-xs">
{formatMessage({ id: 'apiSettings.cliSettings.providerBased' })} {config.label}
</Badge>
);
}
return (
<Badge variant="outline" className="text-xs">
{formatMessage({ id: 'apiSettings.cliSettings.direct' })}
</Badge> </Badge>
); );
}; };
@@ -91,6 +86,12 @@ function CliSettingsCard({
return <Badge variant="success">{formatMessage({ id: 'apiSettings.common.enabled' })}</Badge>; 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 ( return (
<Card className="p-4"> <Card className="p-4">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
@@ -99,7 +100,7 @@ function CliSettingsCard({
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h3 className="text-lg font-semibold text-foreground truncate">{cliSettings.name}</h3> <h3 className="text-lg font-semibold text-foreground truncate">{cliSettings.name}</h3>
{getStatusBadge()} {getStatusBadge()}
{getModeBadge()} {getProviderBadge()}
</div> </div>
{cliSettings.description && ( {cliSettings.description && (
<p className="text-sm text-muted-foreground mt-1">{cliSettings.description}</p> <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"> <div className="flex items-center gap-4 mt-2 text-sm text-muted-foreground">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Settings className="w-3 h-3" /> <Settings className="w-3 h-3" />
{cliSettings.settings.model || 'sonnet'} {cliSettings.settings.model || 'default'}
</span> </span>
{cliSettings.settings.env.ANTHROPIC_BASE_URL && ( {endpointUrl && (
<span className="flex items-center gap-1 truncate max-w-[200px]" title={cliSettings.settings.env.ANTHROPIC_BASE_URL}> <span className="flex items-center gap-1 truncate max-w-[200px]" title={endpointUrl}>
<LinkIcon className="w-3 h-3 flex-shrink-0" /> <LinkIcon className="w-3 h-3 flex-shrink-0" />
{cliSettings.settings.env.ANTHROPIC_BASE_URL} {endpointUrl}
</span>
)}
{cliSettings.settings.includeCoAuthoredBy !== undefined && (
<span>
{formatMessage({ id: 'apiSettings.cliSettings.coAuthoredBy' })}: {formatMessage({ id: cliSettings.settings.includeCoAuthoredBy ? 'common.yes' : 'common.no' })}
</span> </span>
)} )}
</div> </div>
@@ -168,8 +164,7 @@ export function CliSettingsList({
cliSettings, cliSettings,
totalCount, totalCount,
enabledCount, enabledCount,
providerBasedCount, providerCounts,
directCount,
isLoading, isLoading,
refetch, refetch,
} = useCliSettings(); } = useCliSettings();
@@ -219,7 +214,7 @@ export function CliSettingsList({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Stats Cards */} {/* 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"> <Card className="p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Settings className="w-5 h-5 text-primary" /> <Settings className="w-5 h-5 text-primary" />
@@ -240,21 +235,21 @@ export function CliSettingsList({
</Card> </Card>
<Card className="p-4"> <Card className="p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<LinkIcon className="w-5 h-5 text-blue-500" /> <span className="text-2xl font-bold">{providerCounts.claude || 0}</span>
<span className="text-2xl font-bold">{providerBasedCount}</span>
</div> </div>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">Claude</p>
{formatMessage({ id: 'apiSettings.cliSettings.providerBased' })}
</p>
</Card> </Card>
<Card className="p-4"> <Card className="p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Settings className="w-5 h-5 text-orange-500" /> <span className="text-2xl font-bold">{providerCounts.codex || 0}</span>
<span className="text-2xl font-bold">{directCount}</span>
</div> </div>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">Codex</p>
{formatMessage({ id: 'apiSettings.cliSettings.direct' })} </Card>
</p> <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> </Card>
</div> </div>

View File

@@ -1,7 +1,7 @@
// ======================================== // ========================================
// CLI Settings Modal Component // CLI Settings Modal Component
// ======================================== // ========================================
// Add/Edit CLI settings modal with provider-based and direct modes // Add/Edit CLI settings modal with multi-provider support (Claude/Codex/Gemini)
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
@@ -23,7 +23,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
import { useCreateCliSettings, useUpdateCliSettings, useProviders } from '@/hooks/useApiSettings'; import { useCreateCliSettings, useUpdateCliSettings, useProviders } from '@/hooks/useApiSettings';
import { useNotifications } from '@/hooks/useNotifications'; import { useNotifications } from '@/hooks/useNotifications';
import type { CliSettingsEndpoint } from '@/lib/api'; import type { CliSettingsEndpoint, CliProvider } from '@/lib/api';
// ========== Types ========== // ========== Types ==========
@@ -31,34 +31,39 @@ export interface CliSettingsModalProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
cliSettings?: CliSettingsEndpoint | null; cliSettings?: CliSettingsEndpoint | null;
/** Pre-selected provider when creating new */
defaultProvider?: CliProvider;
} }
type ModeType = 'provider-based' | 'direct'; type ModeType = 'provider-based' | 'direct';
// ========== Helper Functions ========== // ========== Helper Functions ==========
function parseConfigJson( function parseConfigText(
configJson: string text: string,
): { ok: true; value: Record<string, unknown> } | { ok: false; errorKey: string } { format: 'json' | 'toml' = 'json'
const trimmed = configJson.trim(); ): { ok: true; value: string } | { ok: false; errorKey: string } {
const trimmed = text.trim();
if (!trimmed) { if (!trimmed) {
return { ok: true, value: {} }; return { ok: true, value: '' };
} }
if (format === 'json') {
try { try {
const parsed = JSON.parse(trimmed) as unknown; JSON.parse(trimmed);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return { ok: true, value: trimmed };
return { ok: false, errorKey: 'configMustBeObject' };
}
return { ok: true, value: parsed as Record<string, unknown> };
} catch { } catch {
return { ok: false, errorKey: 'invalidJson' }; return { ok: false, errorKey: 'invalidJson' };
} }
} }
// TOML: basic format check (no full parser needed)
return { ok: true, value: trimmed };
}
// ========== Main Component ========== // ========== Main Component ==========
export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModalProps) { export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }: CliSettingsModalProps) {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const { error } = useNotifications(); const { error } = useNotifications();
const isEditing = !!cliSettings; const isEditing = !!cliSettings;
@@ -67,83 +72,105 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
const { createCliSettings, isCreating } = useCreateCliSettings(); const { createCliSettings, isCreating } = useCreateCliSettings();
const { updateCliSettings, isUpdating } = useUpdateCliSettings(); const { updateCliSettings, isUpdating } = useUpdateCliSettings();
// Get providers for provider-based mode // Get providers for provider-based mode (Claude)
const { providers } = useProviders(); const { providers } = useProviders();
// Form state // Common form state
const [name, setName] = useState(''); const [name, setName] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [enabled, setEnabled] = useState(true); const [enabled, setEnabled] = useState(true);
const [cliProvider, setCliProvider] = useState<CliProvider>('claude');
// Claude: mode tabs
const [mode, setMode] = useState<ModeType>('direct'); const [mode, setMode] = useState<ModeType>('direct');
// Provider-based mode state
const [providerId, setProviderId] = useState(''); const [providerId, setProviderId] = useState('');
const [model, setModel] = useState('sonnet');
const [includeCoAuthoredBy, setIncludeCoAuthoredBy] = useState(false);
const [settingsFile, setSettingsFile] = useState('');
// Direct mode state
const [authToken, setAuthToken] = useState(''); const [authToken, setAuthToken] = useState('');
const [baseUrl, setBaseUrl] = useState(''); const [baseUrl, setBaseUrl] = useState('');
const [showToken, setShowToken] = useState(false); const [showToken, setShowToken] = useState(false);
const [settingsFile, setSettingsFile] = useState('');
// Available models state // Codex specific
const [codexApiKey, setCodexApiKey] = useState('');
const [codexBaseUrl, setCodexBaseUrl] = useState('');
const [codexProfile, setCodexProfile] = useState('');
const [showCodexKey, setShowCodexKey] = useState(false);
const [authJson, setAuthJson] = useState('');
const [configToml, setConfigToml] = useState('');
const [writeCommonConfig, setWriteCommonConfig] = useState(false);
// Gemini specific
const [geminiApiKey, setGeminiApiKey] = useState('');
const [showGeminiKey, setShowGeminiKey] = useState(false);
// Shared
const [model, setModel] = useState('');
const [availableModels, setAvailableModels] = useState<string[]>([]); const [availableModels, setAvailableModels] = useState<string[]>([]);
const [modelInput, setModelInput] = useState(''); const [modelInput, setModelInput] = useState('');
// Tags state
const [tags, setTags] = useState<string[]>([]); const [tags, setTags] = useState<string[]>([]);
const [tagInput, setTagInput] = useState(''); const [tagInput, setTagInput] = useState('');
// JSON config state
const [configJson, setConfigJson] = useState('{}'); const [configJson, setConfigJson] = useState('{}');
const [showJsonInput, setShowJsonInput] = useState(false); const [showJsonInput, setShowJsonInput] = useState(false);
// Validation errors
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
// Initialize form from cliSettings // Initialize form
useEffect(() => { useEffect(() => {
if (cliSettings) { if (cliSettings) {
setName(cliSettings.name); setName(cliSettings.name);
setDescription(cliSettings.description || ''); setDescription(cliSettings.description || '');
setEnabled(cliSettings.enabled); setEnabled(cliSettings.enabled);
setModel(cliSettings.settings.model || 'sonnet'); setCliProvider(cliSettings.provider || 'claude');
setIncludeCoAuthoredBy(cliSettings.settings.includeCoAuthoredBy || false); setModel(cliSettings.settings.model || '');
setSettingsFile(cliSettings.settings.settingsFile || '');
setAvailableModels(cliSettings.settings.availableModels || []); setAvailableModels(cliSettings.settings.availableModels || []);
setTags(cliSettings.settings.tags || []); setTags(cliSettings.settings.tags || []);
// Determine mode based on settings const provider = cliSettings.provider || 'claude';
if (provider === 'claude') {
setSettingsFile((cliSettings.settings as any).settingsFile || '');
const env = cliSettings.settings.env;
const hasCustomBaseUrl = Boolean( const hasCustomBaseUrl = Boolean(
cliSettings.settings.env.ANTHROPIC_BASE_URL && env.ANTHROPIC_BASE_URL && !env.ANTHROPIC_BASE_URL.includes('api.anthropic.com')
!cliSettings.settings.env.ANTHROPIC_BASE_URL.includes('api.anthropic.com')
); );
if (hasCustomBaseUrl) { if (hasCustomBaseUrl) {
setMode('direct'); setMode('direct');
setBaseUrl(cliSettings.settings.env.ANTHROPIC_BASE_URL || ''); setBaseUrl(env.ANTHROPIC_BASE_URL || '');
setAuthToken(cliSettings.settings.env.ANTHROPIC_AUTH_TOKEN || ''); setAuthToken(env.ANTHROPIC_AUTH_TOKEN || '');
} else { } else {
setMode('provider-based'); setMode('provider-based');
// Try to find matching provider const matchingProvider = providers.find((p) => p.apiBase === env.ANTHROPIC_BASE_URL);
const matchingProvider = providers.find((p) => p.apiBase === cliSettings.settings.env.ANTHROPIC_BASE_URL); if (matchingProvider) setProviderId(matchingProvider.id);
if (matchingProvider) {
setProviderId(matchingProvider.id);
} }
} else if (provider === 'codex') {
const s = cliSettings.settings as any;
setCodexApiKey(s.env.OPENAI_API_KEY || '');
setCodexBaseUrl(s.env.OPENAI_BASE_URL || '');
setCodexProfile(s.profile || '');
setAuthJson(s.authJson || '');
setConfigToml(s.configToml || '');
} else if (provider === 'gemini') {
setGeminiApiKey(cliSettings.settings.env.GEMINI_API_KEY || cliSettings.settings.env.GOOGLE_API_KEY || '');
} }
} else { } else {
// Reset form for new CLI settings // Reset for new
const p = defaultProvider || 'claude';
setName(''); setName('');
setDescription(''); setDescription('');
setEnabled(true); setEnabled(true);
setCliProvider(p);
setMode('direct'); setMode('direct');
setProviderId(''); setProviderId('');
setModel('sonnet'); setModel(p === 'claude' ? 'sonnet' : '');
setIncludeCoAuthoredBy(false);
setSettingsFile(''); setSettingsFile('');
setAuthToken(''); setAuthToken('');
setBaseUrl(''); setBaseUrl('');
setCodexApiKey('');
setCodexBaseUrl('');
setCodexProfile('');
setShowCodexKey(false);
setAuthJson('');
setConfigToml('');
setWriteCommonConfig(false);
setGeminiApiKey('');
setShowGeminiKey(false);
setAvailableModels([]); setAvailableModels([]);
setModelInput(''); setModelInput('');
setTags([]); setTags([]);
@@ -152,7 +179,7 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
setShowJsonInput(false); setShowJsonInput(false);
setErrors({}); setErrors({});
} }
}, [cliSettings, open, providers]); }, [cliSettings, open, providers, defaultProvider]);
// Validate form // Validate form
const validateForm = (): boolean => { const validateForm = (): boolean => {
@@ -161,33 +188,35 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
if (!name.trim()) { if (!name.trim()) {
newErrors.name = formatMessage({ id: 'apiSettings.validation.nameRequired' }); newErrors.name = formatMessage({ id: 'apiSettings.validation.nameRequired' });
} else { } else {
// Validate name format: must start with letter, followed by letters/numbers/hyphens/underscores
const namePattern = /^[a-zA-Z][a-zA-Z0-9_-]*$/; const namePattern = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
if (!namePattern.test(name.trim())) { if (!namePattern.test(name.trim())) {
newErrors.name = formatMessage({ id: 'apiSettings.cliSettings.nameFormatHint' }); newErrors.name = formatMessage({ id: 'apiSettings.cliSettings.nameFormatHint' });
} }
// Validate name length
if (name.trim().length > 32) { if (name.trim().length > 32) {
newErrors.name = formatMessage({ id: 'apiSettings.cliSettings.nameTooLong' }, { max: 32 }); newErrors.name = formatMessage({ id: 'apiSettings.cliSettings.nameTooLong' }, { max: 32 });
} }
} }
if (mode === 'provider-based') { if (cliProvider === 'claude') {
if (!providerId) { if (mode === 'provider-based' && !providerId) {
newErrors.providerId = formatMessage({ id: 'apiSettings.cliSettings.validation.providerRequired' }); newErrors.providerId = formatMessage({ id: 'apiSettings.cliSettings.validation.providerRequired' });
} }
} else { if (mode === 'direct' && !authToken.trim() && !baseUrl.trim()) {
// Direct mode
if (!authToken.trim() && !baseUrl.trim()) {
newErrors.direct = formatMessage({ id: 'apiSettings.cliSettings.validation.authOrBaseUrlRequired' }); newErrors.direct = formatMessage({ id: 'apiSettings.cliSettings.validation.authOrBaseUrlRequired' });
} }
} }
// Validate JSON config if shown if (authJson.trim()) {
const parsed = parseConfigText(authJson, 'json');
if (!parsed.ok) {
newErrors.authJson = formatMessage({ id: `apiSettings.cliSettings.${parsed.errorKey}` });
}
}
if (showJsonInput) { if (showJsonInput) {
const parsedConfig = parseConfigJson(configJson); const parsed = parseConfigText(configJson, 'json');
if (!parsedConfig.ok) { if (!parsed.ok) {
newErrors.configJson = formatMessage({ id: `apiSettings.cliSettings.${parsedConfig.errorKey}` }); newErrors.configJson = formatMessage({ id: `apiSettings.cliSettings.${parsed.errorKey}` });
} }
} }
@@ -200,38 +229,59 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
if (!validateForm()) return; if (!validateForm()) return;
try { try {
// Build settings object based on mode let settings: any;
const env: Record<string, string> = {
DISABLE_AUTOUPDATER: '1',
};
if (cliProvider === 'claude') {
const env: Record<string, string> = { DISABLE_AUTOUPDATER: '1' };
if (mode === 'provider-based') { if (mode === 'provider-based') {
// Provider-based mode: get settings from selected provider
const provider = providers.find((p) => p.id === providerId); const provider = providers.find((p) => p.id === providerId);
if (provider) { if (provider?.apiBase) env.ANTHROPIC_BASE_URL = provider.apiBase;
if (provider.apiBase) { if (provider?.apiKey) env.ANTHROPIC_AUTH_TOKEN = provider.apiKey;
env.ANTHROPIC_BASE_URL = provider.apiBase;
}
if (provider.apiKey) {
env.ANTHROPIC_AUTH_TOKEN = provider.apiKey;
}
}
} else { } else {
// Direct mode: use manual input if (authToken.trim()) env.ANTHROPIC_AUTH_TOKEN = authToken.trim();
if (authToken.trim()) { if (baseUrl.trim()) env.ANTHROPIC_BASE_URL = baseUrl.trim();
env.ANTHROPIC_AUTH_TOKEN = authToken.trim();
}
if (baseUrl.trim()) {
env.ANTHROPIC_BASE_URL = baseUrl.trim();
} }
settings = {
env,
model: model || 'sonnet',
settingsFile: settingsFile.trim() || undefined,
availableModels,
tags,
};
} else if (cliProvider === 'codex') {
const env: Record<string, string> = {};
if (codexApiKey.trim()) env.OPENAI_API_KEY = codexApiKey.trim();
if (codexBaseUrl.trim()) env.OPENAI_BASE_URL = codexBaseUrl.trim();
settings = {
env,
model: model || undefined,
profile: codexProfile.trim() || undefined,
authJson: authJson.trim() || undefined,
configToml: configToml.trim() || undefined,
availableModels,
tags,
};
} else {
// gemini
const env: Record<string, string> = {};
if (geminiApiKey.trim()) env.GEMINI_API_KEY = geminiApiKey.trim();
settings = {
env,
model: model || undefined,
availableModels,
tags,
};
} }
// Parse and merge JSON config if shown // Merge JSON config if shown
let extraSettings: Record<string, unknown> = {}; if (showJsonInput && configJson.trim()) {
if (showJsonInput) { try {
const parsedConfig = parseConfigJson(configJson); const extra = JSON.parse(configJson);
if (parsedConfig.ok) { if (extra && typeof extra === 'object' && !Array.isArray(extra)) {
extraSettings = parsedConfig.value; Object.assign(settings, extra);
}
} catch {
// skip invalid json
} }
} }
@@ -239,16 +289,9 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
id: cliSettings?.id, id: cliSettings?.id,
name: name.trim(), name: name.trim(),
description: description.trim() || undefined, description: description.trim() || undefined,
provider: cliProvider,
enabled, enabled,
settings: { settings,
env,
model,
includeCoAuthoredBy,
settingsFile: settingsFile.trim() || undefined,
availableModels,
tags,
...extraSettings,
},
}; };
if (isEditing && cliSettings) { if (isEditing && cliSettings) {
@@ -263,40 +306,34 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
} }
}; };
// Handle add model // Tag/Model helpers
const handleAddModel = () => { const handleAddModel = () => {
const newModel = modelInput.trim(); const v = modelInput.trim();
if (newModel && !availableModels.includes(newModel)) { if (v && !availableModels.includes(v)) {
setAvailableModels([...availableModels, newModel]); setAvailableModels([...availableModels, v]);
setModelInput(''); setModelInput('');
} }
}; };
const handleRemoveModel = (m: string) => setAvailableModels(availableModels.filter((x) => x !== m));
// Handle remove model
const handleRemoveModel = (modelToRemove: string) => {
setAvailableModels(availableModels.filter((m) => m !== modelToRemove));
};
// Handle add tag
const handleAddTag = () => { const handleAddTag = () => {
const newTag = tagInput.trim(); const v = tagInput.trim();
if (newTag && !tags.includes(newTag)) { if (v && !tags.includes(v)) {
setTags([...tags, newTag]); setTags([...tags, v]);
setTagInput(''); setTagInput('');
} }
}; };
const handleRemoveTag = (t: string) => setTags(tags.filter((x) => x !== t));
// Handle remove tag
const handleRemoveTag = (tagToRemove: string) => {
setTags(tags.filter((t) => t !== tagToRemove));
};
// Predefined tags
const predefinedTags = ['分析', 'Debug', 'implementation', 'refactoring', 'testing']; const predefinedTags = ['分析', 'Debug', 'implementation', 'refactoring', 'testing'];
// Get selected provider info
const selectedProvider = providers.find((p) => p.id === providerId); const selectedProvider = providers.find((p) => p.id === providerId);
// Title by provider
const providerLabel: Record<CliProvider, string> = {
claude: 'Claude',
codex: 'Codex',
gemini: 'Gemini',
};
return ( return (
<Dialog open={open} onOpenChange={onClose}> <Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto"> <DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
@@ -305,6 +342,7 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
{isEditing {isEditing
? formatMessage({ id: 'apiSettings.cliSettings.actions.edit' }) ? formatMessage({ id: 'apiSettings.cliSettings.actions.edit' })
: formatMessage({ id: 'apiSettings.cliSettings.actions.add' })} : formatMessage({ id: 'apiSettings.cliSettings.actions.add' })}
{' - '}{providerLabel[cliProvider]}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{formatMessage({ id: 'apiSettings.cliSettings.modalDescription' })} {formatMessage({ id: 'apiSettings.cliSettings.modalDescription' })}
@@ -314,6 +352,23 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
<div className="space-y-6 py-4"> <div className="space-y-6 py-4">
{/* Common Fields */} {/* Common Fields */}
<div className="space-y-4"> <div className="space-y-4">
{/* Provider Selector (only when creating new) */}
{!isEditing && (
<div className="space-y-2">
<Label>CLI Provider</Label>
<Select value={cliProvider} onValueChange={(v) => setCliProvider(v as CliProvider)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="claude">Claude</SelectItem>
<SelectItem value="codex">Codex</SelectItem>
<SelectItem value="gemini">Gemini</SelectItem>
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name"> <Label htmlFor="name">
{formatMessage({ id: 'apiSettings.common.name' })} {formatMessage({ id: 'apiSettings.common.name' })}
@@ -347,18 +402,16 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Switch <Switch id="enabled" checked={enabled} onCheckedChange={setEnabled} />
id="enabled"
checked={enabled}
onCheckedChange={setEnabled}
/>
<Label htmlFor="enabled" className="cursor-pointer"> <Label htmlFor="enabled" className="cursor-pointer">
{formatMessage({ id: 'apiSettings.common.enableThis' })} {formatMessage({ id: 'apiSettings.common.enableThis' })}
</Label> </Label>
</div> </div>
</div> </div>
{/* Mode Tabs */} {/* ========== Claude Settings ========== */}
{cliProvider === 'claude' && (
<>
<Tabs value={mode} onValueChange={(v) => setMode(v as ModeType)} className="w-full"> <Tabs value={mode} onValueChange={(v) => setMode(v as ModeType)} className="w-full">
<TabsList className="grid w-full grid-cols-2"> <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="provider-based"> <TabsTrigger value="provider-based">
@@ -369,7 +422,6 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
{/* Provider-based Mode */}
<TabsContent value="provider-based" className="space-y-4 mt-4"> <TabsContent value="provider-based" className="space-y-4 mt-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="providerId"> <Label htmlFor="providerId">
@@ -381,10 +433,8 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
<SelectValue placeholder={formatMessage({ id: 'apiSettings.cliSettings.selectProvider' })} /> <SelectValue placeholder={formatMessage({ id: 'apiSettings.cliSettings.selectProvider' })} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{providers.map((provider) => ( {providers.map((p) => (
<SelectItem key={provider.id} value={provider.id}> <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
{provider.name}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
@@ -406,28 +456,14 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
)} )}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="model-pb"> <Label htmlFor="model-pb">{formatMessage({ id: 'apiSettings.cliSettings.model' })}</Label>
{formatMessage({ id: 'apiSettings.cliSettings.model' })} <Input id="model-pb" value={model} onChange={(e) => setModel(e.target.value)} placeholder="sonnet" />
</Label>
<Select value={model} onValueChange={setModel}>
<SelectTrigger id="model-pb">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="opus">Opus</SelectItem>
<SelectItem value="sonnet">Sonnet</SelectItem>
<SelectItem value="haiku">Haiku</SelectItem>
</SelectContent>
</Select>
</div> </div>
</TabsContent> </TabsContent>
{/* Direct Mode */}
<TabsContent value="direct" className="space-y-4 mt-4"> <TabsContent value="direct" className="space-y-4 mt-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="authToken"> <Label htmlFor="authToken">{formatMessage({ id: 'apiSettings.cliSettings.authToken' })}</Label>
{formatMessage({ id: 'apiSettings.cliSettings.authToken' })}
</Label>
<div className="relative"> <div className="relative">
<Input <Input
id="authToken" id="authToken"
@@ -438,9 +474,7 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
className={errors.direct ? 'border-destructive pr-10' : 'pr-10'} className={errors.direct ? 'border-destructive pr-10' : 'pr-10'}
/> />
<Button <Button
type="button" type="button" variant="ghost" size="icon"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-2" className="absolute right-0 top-0 h-full px-2"
onClick={() => setShowToken(!showToken)} onClick={() => setShowToken(!showToken)}
> >
@@ -450,9 +484,7 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="baseUrl"> <Label htmlFor="baseUrl">{formatMessage({ id: 'apiSettings.cliSettings.baseUrl' })}</Label>
{formatMessage({ id: 'apiSettings.cliSettings.baseUrl' })}
</Label>
<Input <Input
id="baseUrl" id="baseUrl"
value={baseUrl} value={baseUrl}
@@ -461,44 +493,18 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
className={errors.direct ? 'border-destructive' : ''} className={errors.direct ? 'border-destructive' : ''}
/> />
</div> </div>
{errors.direct && <p className="text-sm text-destructive">{errors.direct}</p>} {errors.direct && <p className="text-sm text-destructive">{errors.direct}</p>}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="model-direct"> <Label htmlFor="model-direct">{formatMessage({ id: 'apiSettings.cliSettings.model' })}</Label>
{formatMessage({ id: 'apiSettings.cliSettings.model' })} <Input id="model-direct" value={model} onChange={(e) => setModel(e.target.value)} placeholder="sonnet" />
</Label>
<Select value={model} onValueChange={setModel}>
<SelectTrigger id="model-direct">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="opus">Opus</SelectItem>
<SelectItem value="sonnet">Sonnet</SelectItem>
<SelectItem value="haiku">Haiku</SelectItem>
</SelectContent>
</Select>
</div> </div>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
{/* Additional Settings (both modes) */} {/* Claude: Settings File */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Switch
id="coAuthored"
checked={includeCoAuthoredBy}
onCheckedChange={setIncludeCoAuthoredBy}
/>
<Label htmlFor="coAuthored" className="cursor-pointer">
{formatMessage({ id: 'apiSettings.cliSettings.includeCoAuthoredBy' })}
</Label>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="settingsFile"> <Label htmlFor="settingsFile">{formatMessage({ id: 'apiSettings.cliSettings.settingsFile' })}</Label>
{formatMessage({ id: 'apiSettings.cliSettings.settingsFile' })}
</Label>
<Input <Input
id="settingsFile" id="settingsFile"
value={settingsFile} value={settingsFile}
@@ -509,26 +515,179 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
{formatMessage({ id: 'apiSettings.cliSettings.settingsFileHint' })} {formatMessage({ id: 'apiSettings.cliSettings.settingsFileHint' })}
</p> </p>
</div> </div>
</>
)}
{/* Available Models Section */} {/* ========== Codex Settings ========== */}
{cliProvider === 'codex' && (
<div className="space-y-4">
{/* API Key */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="availableModels"> <Label htmlFor="codex-apikey">API Key</Label>
{formatMessage({ id: 'apiSettings.cliSettings.availableModels' })} <p className="text-xs text-muted-foreground">
</Label> auth.json
</p>
<div className="relative">
<Input
id="codex-apikey"
type={showCodexKey ? 'text' : 'password'}
value={codexApiKey}
onChange={(e) => setCodexApiKey(e.target.value)}
placeholder="sk-..."
className="pr-10"
/>
<Button
type="button" variant="ghost" size="icon"
className="absolute right-0 top-0 h-full px-2"
onClick={() => setShowCodexKey(!showCodexKey)}
>
{showCodexKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
</div>
</div>
{/* API Endpoint URL */}
<div className="space-y-2">
<Label htmlFor="codex-baseurl">API </Label>
<Input
id="codex-baseurl"
value={codexBaseUrl}
onChange={(e) => setCodexBaseUrl(e.target.value)}
placeholder="https://your-api-endpoint.com/v1"
/>
<p className="text-xs text-muted-foreground">
OpenAI Response
</p>
</div>
{/* Model Name */}
<div className="space-y-2">
<Label htmlFor="codex-model">{formatMessage({ id: 'apiSettings.cliSettings.model' })}</Label>
<Input
id="codex-model"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="gpt-5.2"
/>
<p className="text-xs text-muted-foreground">
使 config.toml
</p>
</div>
{/* Profile */}
<div className="space-y-2">
<Label htmlFor="codex-profile">Profile</Label>
<Input
id="codex-profile"
value={codexProfile}
onChange={(e) => setCodexProfile(e.target.value)}
placeholder="default"
/>
<p className="text-xs text-muted-foreground">
Codex profile --profile
</p>
</div>
{/* auth.json Editor */}
<div className="space-y-2">
<Label htmlFor="codex-authjson">auth.json (JSON) *</Label>
<Textarea
id="codex-authjson"
value={authJson}
onChange={(e) => {
setAuthJson(e.target.value);
if (errors.authJson) {
setErrors((prev) => { const n = { ...prev }; delete n.authJson; return n; });
}
}}
placeholder='{"OPENAI_API_KEY": "..."}'
className={`font-mono text-sm ${errors.authJson ? 'border-destructive' : ''}`}
rows={8}
/>
{errors.authJson && <p className="text-xs text-destructive">{errors.authJson}</p>}
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Codex auth.json </p>
<Button
type="button" variant="ghost" size="sm"
onClick={() => {
try {
const formatted = JSON.stringify(JSON.parse(authJson), null, 2);
setAuthJson(formatted);
} catch { /* skip */ }
}}
>
</Button>
</div>
</div>
{/* config.toml Editor */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="codex-configtoml">config.toml (TOML)</Label>
<div className="flex items-center gap-2">
<Switch id="writeCommon" checked={writeCommonConfig} onCheckedChange={setWriteCommonConfig} />
<Label htmlFor="writeCommon" className="text-xs cursor-pointer"></Label>
</div>
</div>
<Textarea
id="codex-configtoml"
value={configToml}
onChange={(e) => setConfigToml(e.target.value)}
placeholder={'model = "gpt-5.2"\nmodel_reasoning_effort = "xhigh"'}
className="font-mono text-sm"
rows={6}
/>
</div>
</div>
)}
{/* ========== Gemini Settings ========== */}
{cliProvider === 'gemini' && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="gemini-apikey">API Key</Label>
<div className="relative">
<Input
id="gemini-apikey"
type={showGeminiKey ? 'text' : 'password'}
value={geminiApiKey}
onChange={(e) => setGeminiApiKey(e.target.value)}
placeholder="AIza..."
className="pr-10"
/>
<Button
type="button" variant="ghost" size="icon"
className="absolute right-0 top-0 h-full px-2"
onClick={() => setShowGeminiKey(!showGeminiKey)}
>
{showGeminiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="gemini-model">{formatMessage({ id: 'apiSettings.cliSettings.model' })}</Label>
<Input
id="gemini-model"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="gemini-2.5-flash"
/>
</div>
</div>
)}
{/* ========== Shared Settings (all providers) ========== */}
<div className="space-y-4">
{/* Available Models */}
<div className="space-y-2">
<Label htmlFor="availableModels">{formatMessage({ id: 'apiSettings.cliSettings.availableModels' })}</Label>
<div className="flex flex-wrap gap-2 p-3 bg-muted/30 rounded-lg min-h-[60px]"> <div className="flex flex-wrap gap-2 p-3 bg-muted/30 rounded-lg min-h-[60px]">
{availableModels.map((model) => ( {availableModels.map((m) => (
<span <span key={m} className="inline-flex items-center gap-1 px-2 py-1 bg-primary/10 text-primary rounded-md text-sm">
key={model} {m}
className="inline-flex items-center gap-1 px-2 py-1 bg-primary/10 text-primary rounded-md text-sm" <button type="button" onClick={() => handleRemoveModel(m)} className="hover:text-destructive transition-colors">×</button>
>
{model}
<button
type="button"
onClick={() => handleRemoveModel(model)}
className="hover:text-destructive transition-colors"
>
×
</button>
</span> </span>
))} ))}
<div className="flex gap-2 flex-1"> <div className="flex gap-2 flex-1">
@@ -536,21 +695,11 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
id="availableModels" id="availableModels"
value={modelInput} value={modelInput}
onChange={(e) => setModelInput(e.target.value)} onChange={(e) => setModelInput(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddModel(); } }}
if (e.key === 'Enter') {
e.preventDefault();
handleAddModel();
}
}}
placeholder={formatMessage({ id: 'apiSettings.cliSettings.availableModelsPlaceholder' })} placeholder={formatMessage({ id: 'apiSettings.cliSettings.availableModelsPlaceholder' })}
className="flex-1 min-w-[120px]" className="flex-1 min-w-[120px]"
/> />
<Button <Button type="button" size="sm" onClick={handleAddModel} variant="outline">
type="button"
size="sm"
onClick={handleAddModel}
variant="outline"
>
<Check className="w-4 h-4" /> <Check className="w-4 h-4" />
</Button> </Button>
</div> </div>
@@ -560,20 +709,15 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
</p> </p>
</div> </div>
{/* Tags Section */} {/* Tags */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="tags"> <Label htmlFor="tags">{formatMessage({ id: 'apiSettings.cliSettings.tags' })}</Label>
{formatMessage({ id: 'apiSettings.cliSettings.tags' })}
</Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{formatMessage({ id: 'apiSettings.cliSettings.tagsDescription' })} {formatMessage({ id: 'apiSettings.cliSettings.tagsDescription' })}
</p> </p>
<div className="flex flex-wrap gap-2 p-3 bg-muted/30 rounded-lg min-h-[60px]"> <div className="flex flex-wrap gap-2 p-3 bg-muted/30 rounded-lg min-h-[60px]">
{tags.map((tag) => ( {tags.map((tag) => (
<span <span key={tag} className="inline-flex items-center gap-1 px-2 py-1 bg-primary/10 text-primary rounded-md text-sm">
key={tag}
className="inline-flex items-center gap-1 px-2 py-1 bg-primary/10 text-primary rounded-md text-sm"
>
{tag} {tag}
<button <button
type="button" type="button"
@@ -590,58 +734,41 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
id="tags" id="tags"
value={tagInput} value={tagInput}
onChange={(e) => setTagInput(e.target.value)} onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddTag(); } }}
if (e.key === 'Enter') {
e.preventDefault();
handleAddTag();
}
}}
placeholder={formatMessage({ id: 'apiSettings.cliSettings.tagInputPlaceholder' })} placeholder={formatMessage({ id: 'apiSettings.cliSettings.tagInputPlaceholder' })}
className="flex-1 min-w-[120px]" className="flex-1 min-w-[120px]"
/> />
<Button <Button type="button" size="sm" onClick={handleAddTag} variant="outline">
type="button"
size="sm"
onClick={handleAddTag}
variant="outline"
>
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
</Button> </Button>
</div> </div>
</div> </div>
{/* Predefined Tags */}
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{formatMessage({ id: 'apiSettings.cliSettings.predefinedTags' })}: {formatMessage({ id: 'apiSettings.cliSettings.predefinedTags' })}:
</span> </span>
{predefinedTags.map((predefinedTag) => ( {predefinedTags.map((pt) => (
<button <button
key={predefinedTag} key={pt}
type="button" type="button"
onClick={() => { onClick={() => { if (!tags.includes(pt)) setTags([...tags, pt]); }}
if (!tags.includes(predefinedTag)) { disabled={tags.includes(pt)}
setTags([...tags, predefinedTag]);
}
}}
disabled={tags.includes(predefinedTag)}
className="text-xs px-2 py-0.5 rounded border border-border hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors" className="text-xs px-2 py-0.5 rounded border border-border hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
> >
{predefinedTag} {pt}
</button> </button>
))} ))}
</div> </div>
</div> </div>
{/* JSON Config Section */} {/* JSON Config (all providers) */}
<div className="space-y-2 pt-4 border-t border-border"> <div className="space-y-2 pt-4 border-t border-border">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label htmlFor="configJson" className="cursor-pointer"> <Label htmlFor="configJson" className="cursor-pointer">
{formatMessage({ id: 'apiSettings.cliSettings.configJson' })} {formatMessage({ id: 'apiSettings.cliSettings.configJson' })}
</Label> </Label>
<Button <Button
type="button" type="button" variant="ghost" size="sm"
variant="ghost"
size="sm"
onClick={() => setShowJsonInput(!showJsonInput)} onClick={() => setShowJsonInput(!showJsonInput)}
> >
{showJsonInput {showJsonInput
@@ -657,20 +784,14 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
onChange={(e) => { onChange={(e) => {
setConfigJson(e.target.value); setConfigJson(e.target.value);
if (errors.configJson) { if (errors.configJson) {
setErrors((prev) => { setErrors((prev) => { const n = { ...prev }; delete n.configJson; return n; });
const newErrors = { ...prev };
delete newErrors.configJson;
return newErrors;
});
} }
}} }}
placeholder={formatMessage({ id: 'apiSettings.cliSettings.configJsonPlaceholder' })} placeholder={formatMessage({ id: 'apiSettings.cliSettings.configJsonPlaceholder' })}
className={errors.configJson ? 'font-mono border-destructive' : 'font-mono'} className={errors.configJson ? 'font-mono border-destructive' : 'font-mono'}
rows={8} rows={8}
/> />
{errors.configJson && ( {errors.configJson && <p className="text-xs text-destructive">{errors.configJson}</p>}
<p className="text-xs text-destructive">{errors.configJson}</p>
)}
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{formatMessage({ id: 'apiSettings.cliSettings.configJsonHint' })} {formatMessage({ id: 'apiSettings.cliSettings.configJsonHint' })}
</p> </p>

View File

@@ -26,8 +26,10 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/Select'; } from '@/components/ui/Select';
import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup'; 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 LaunchMode = 'default' | 'yolo';
export type ShellKind = 'bash' | 'pwsh' | 'cmd'; export type ShellKind = 'bash' | 'pwsh' | 'cmd';
@@ -39,6 +41,8 @@ export interface CliSessionConfig {
workingDir: string; workingDir: string;
/** Session tag for grouping (auto-generated if not provided) */ /** Session tag for grouping (auto-generated if not provided) */
tag: string; tag: string;
/** CLI Settings endpoint ID for custom API configuration */
settingsEndpointId?: string;
} }
export interface CliConfigModalProps { export interface CliConfigModalProps {
@@ -48,23 +52,13 @@ export interface CliConfigModalProps {
onCreateSession: (config: CliSessionConfig) => Promise<void>; 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__'; const AUTO_MODEL_VALUE = '__auto__';
/** /**
* Generate a tag name: {tool}-{HHmmss} * Generate a tag name: {tool}-{HHmmss}
* Example: gemini-143052 * Example: gemini-143052
*/ */
function generateTag(tool: CliTool): string { function generateTag(tool: string): string {
const now = new Date(); const now = new Date();
const time = now.toTimeString().slice(0, 8).replace(/:/g, ''); const time = now.toTimeString().slice(0, 8).replace(/:/g, '');
return `${tool}-${time}`; return `${tool}-${time}`;
@@ -78,8 +72,18 @@ export function CliConfigModal({
}: CliConfigModalProps) { }: CliConfigModalProps) {
const { formatMessage } = useIntl(); 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 [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'); const [launchMode, setLaunchMode] = React.useState<LaunchMode>('yolo');
// Default to 'cmd' on Windows for better compatibility with npm CLI tools (.cmd files) // Default to 'cmd' on Windows for better compatibility with npm CLI tools (.cmd files)
const [preferredShell, setPreferredShell] = React.useState<ShellKind>( const [preferredShell, setPreferredShell] = React.useState<ShellKind>(
@@ -91,7 +95,46 @@ export function CliConfigModal({
const [isSubmitting, setIsSubmitting] = React.useState(false); const [isSubmitting, setIsSubmitting] = React.useState(false);
const [error, setError] = React.useState<string | null>(null); 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 // Generate new tag when modal opens or tool changes
const regenerateTag = React.useCallback(() => { const regenerateTag = React.useCallback(() => {
@@ -104,6 +147,7 @@ export function CliConfigModal({
const nextWorkingDir = defaultWorkingDir ?? ''; const nextWorkingDir = defaultWorkingDir ?? '';
setWorkingDir(nextWorkingDir); setWorkingDir(nextWorkingDir);
setError(null); setError(null);
setSettingsEndpointId(undefined);
regenerateTag(); regenerateTag();
}, [isOpen, defaultWorkingDir, regenerateTag]); }, [isOpen, defaultWorkingDir, regenerateTag]);
@@ -113,12 +157,23 @@ export function CliConfigModal({
const suffix = tag.split('-').pop() || ''; const suffix = tag.split('-').pop() || '';
setTag(`${tool}-${suffix}`); setTag(`${tool}-${suffix}`);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps -- only re-run when tool changes, reading tag intentionally stale
}, [tool]); }, [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 handleToolChange = (nextTool: string) => {
const next = nextTool as CliTool; setTool(nextTool as CliTool);
setTool(next); const nextConfig = cliTools[nextTool];
const nextModels = MODEL_OPTIONS[next] ?? []; const nextModels = nextConfig?.availableModels?.length
? nextConfig.availableModels
: [nextConfig?.primaryModel, nextConfig?.secondaryModel].filter(Boolean) as string[];
if (!model || !nextModels.includes(model)) { if (!model || !nextModels.includes(model)) {
setModel(nextModels[0]); setModel(nextModels[0]);
} }
@@ -148,6 +203,7 @@ export function CliConfigModal({
preferredShell, preferredShell,
workingDir: dir, workingDir: dir,
tag: finalTag, tag: finalTag,
settingsEndpointId,
}); });
onClose(); onClose();
} catch (err) { } catch (err) {
@@ -209,7 +265,7 @@ export function CliConfigModal({
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{CLI_TOOLS.map((t) => ( {enabledTools.map((t) => (
<SelectItem key={t} value={t}> <SelectItem key={t} value={t}>
{t} {t}
</SelectItem> </SelectItem>
@@ -245,6 +301,45 @@ export function CliConfigModal({
</div> </div>
</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 */} {/* Mode */}
<div className="space-y-2"> <div className="space-y-2">
<Label>{formatMessage({ id: 'terminalDashboard.cliConfig.mode' })}</Label> <Label>{formatMessage({ id: 'terminalDashboard.cliConfig.mode' })}</Label>

View File

@@ -699,8 +699,8 @@ export interface UseCliSettingsReturn {
cliSettings: CliSettingsEndpoint[]; cliSettings: CliSettingsEndpoint[];
totalCount: number; totalCount: number;
enabledCount: number; enabledCount: number;
providerBasedCount: number; /** Count per provider type */
directCount: number; providerCounts: Record<string, number>;
isLoading: boolean; isLoading: boolean;
isFetching: boolean; isFetching: boolean;
error: Error | null; error: Error | null;
@@ -723,13 +723,12 @@ export function useCliSettings(options: UseCliSettingsOptions = {}): UseCliSetti
const cliSettings = query.data?.endpoints ?? []; const cliSettings = query.data?.endpoints ?? [];
const enabledCliSettings = cliSettings.filter((s) => s.enabled); const enabledCliSettings = cliSettings.filter((s) => s.enabled);
// Determine mode based on whether settings have providerId in description or env vars // Count settings per provider type
const providerBasedCount = cliSettings.filter((s) => { const providerCounts = cliSettings.reduce<Record<string, number>>((acc, s) => {
// Provider-based: has ANTHROPIC_BASE_URL set to provider's apiBase const provider = s.provider || 'claude';
return s.settings.env.ANTHROPIC_BASE_URL && !s.settings.env.ANTHROPIC_BASE_URL.includes('api.anthropic.com'); acc[provider] = (acc[provider] || 0) + 1;
}).length; return acc;
}, {});
const directCount = cliSettings.length - providerBasedCount;
const refetch = async () => { const refetch = async () => {
await query.refetch(); await query.refetch();
@@ -743,8 +742,7 @@ export function useCliSettings(options: UseCliSettingsOptions = {}): UseCliSetti
cliSettings, cliSettings,
totalCount: cliSettings.length, totalCount: cliSettings.length,
enabledCount: enabledCliSettings.length, enabledCount: enabledCliSettings.length,
providerBasedCount, providerCounts,
directCount,
isLoading: query.isLoading, isLoading: query.isLoading,
isFetching: query.isFetching, isFetching: query.isFetching,
error: query.error, error: query.error,

View File

@@ -6020,23 +6020,50 @@ export async function uninstallCcwLitellm(): Promise<{ success: boolean; message
* CLI Settings (Claude CLI endpoint configuration) * CLI Settings (Claude CLI endpoint configuration)
* Maps to backend EndpointSettings from /api/cli/settings * 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 { export interface CliSettingsEndpoint {
id: string; id: string;
name: string; name: string;
description?: string; description?: string;
settings: { /** CLI provider type (defaults to 'claude' for backward compat) */
env: { provider: CliProvider;
ANTHROPIC_AUTH_TOKEN?: string; settings: ClaudeCliSettingsApi | CodexCliSettingsApi | GeminiCliSettingsApi;
ANTHROPIC_BASE_URL?: string;
DISABLE_AUTOUPDATER?: string;
[key: string]: string | undefined;
};
model?: string;
includeCoAuthoredBy?: boolean;
settingsFile?: string;
availableModels?: string[];
tags?: string[];
};
enabled: boolean; enabled: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -6057,19 +6084,9 @@ export interface SaveCliSettingsRequest {
id?: string; id?: string;
name: string; name: string;
description?: string; description?: string;
settings: { /** CLI provider type */
env: { provider?: CliProvider;
ANTHROPIC_AUTH_TOKEN?: string; settings: ClaudeCliSettingsApi | CodexCliSettingsApi | GeminiCliSettingsApi;
ANTHROPIC_BASE_URL?: string;
DISABLE_AUTOUPDATER?: string;
[key: string]: string | undefined;
};
model?: string;
includeCoAuthoredBy?: boolean;
settingsFile?: string;
availableModels?: string[];
tags?: string[];
};
enabled?: boolean; enabled?: boolean;
} }
@@ -6565,6 +6582,8 @@ export interface CreateCliSessionInput {
resumeKey?: string; resumeKey?: string;
/** Launch mode for native CLI sessions (default or yolo). */ /** Launch mode for native CLI sessions (default or yolo). */
launchMode?: 'default' | '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 { function withPath(url: string, projectPath?: string): string {

View File

@@ -10,7 +10,8 @@ import { join } from 'path';
import * as os from 'os'; import * as os from 'os';
import { getCCWHome, ensureStorageDir } from './storage-paths.js'; import { getCCWHome, ensureStorageDir } from './storage-paths.js';
import { import {
ClaudeCliSettings, CliSettings,
CliProvider,
EndpointSettings, EndpointSettings,
SettingsListResponse, SettingsListResponse,
SettingsOperationResult, SettingsOperationResult,
@@ -19,8 +20,8 @@ import {
createDefaultSettings createDefaultSettings
} from '../types/cli-settings.js'; } from '../types/cli-settings.js';
import { import {
addClaudeCustomEndpoint, addCustomEndpoint,
removeClaudeCustomEndpoint removeCustomEndpoint
} from '../tools/claude-cli-tools.js'; } from '../tools/claude-cli-tools.js';
/** /**
@@ -108,6 +109,7 @@ export function saveEndpointSettings(request: SaveEndpointRequest): SettingsOper
id: endpointId, id: endpointId,
name: request.name, name: request.name,
description: request.description, description: request.description,
provider: request.provider || 'claude',
enabled: request.enabled ?? true, enabled: request.enabled ?? true,
createdAt: existing?.createdAt || now, createdAt: existing?.createdAt || now,
updatedAt: now updatedAt: now
@@ -129,13 +131,13 @@ export function saveEndpointSettings(request: SaveEndpointRequest): SettingsOper
// Merge user-provided tags with cli-wrapper tag for proper type registration // Merge user-provided tags with cli-wrapper tag for proper type registration
const userTags = request.settings.tags || []; const userTags = request.settings.tags || [];
const tags = [...new Set([...userTags, 'cli-wrapper'])]; // Dedupe and ensure cli-wrapper tag const tags = [...new Set([...userTags, 'cli-wrapper'])]; // Dedupe and ensure cli-wrapper tag
addClaudeCustomEndpoint(projectDir, { addCustomEndpoint(projectDir, {
id: endpointId, id: endpointId,
name: request.name, name: request.name,
enabled: request.enabled ?? true, enabled: request.enabled ?? true,
tags, tags,
availableModels: request.settings.availableModels, availableModels: request.settings.availableModels,
settingsFile: request.settings.settingsFile settingsFile: 'settingsFile' in request.settings ? (request.settings as any).settingsFile : undefined
}); });
console.log(`[CliSettings] Synced endpoint ${endpointId} to cli-tools.json tools (cli-wrapper)`); console.log(`[CliSettings] Synced endpoint ${endpointId} to cli-tools.json tools (cli-wrapper)`);
} catch (syncError) { } catch (syncError) {
@@ -182,13 +184,15 @@ export function loadEndpointSettings(endpointId: string): EndpointSettings | nul
const settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
if (!validateSettings(settings)) { const provider = (metadata as any).provider || 'claude';
if (!validateSettings(settings, provider)) {
console.error(`[CliSettings] Invalid settings format for ${endpointId}`); console.error(`[CliSettings] Invalid settings format for ${endpointId}`);
return null; return null;
} }
return { return {
...metadata, ...metadata,
provider,
settings settings
}; };
} catch (e) { } catch (e) {
@@ -225,7 +229,7 @@ export function deleteEndpointSettings(endpointId: string): SettingsOperationRes
// Step 3: Remove from cli-tools.json tools (api-endpoint type) // Step 3: Remove from cli-tools.json tools (api-endpoint type)
try { try {
const projectDir = os.homedir(); const projectDir = os.homedir();
removeClaudeCustomEndpoint(projectDir, endpointId); removeCustomEndpoint(projectDir, endpointId);
console.log(`[CliSettings] Removed endpoint ${endpointId} from cli-tools.json tools`); console.log(`[CliSettings] Removed endpoint ${endpointId} from cli-tools.json tools`);
} catch (syncError) { } catch (syncError) {
console.warn(`[CliSettings] Failed to remove from cli-tools.json: ${syncError}`); console.warn(`[CliSettings] Failed to remove from cli-tools.json: ${syncError}`);
@@ -262,10 +266,12 @@ export function listAllSettings(): SettingsListResponse {
try { try {
const settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
const provider: CliProvider = (metadata as any).provider || 'claude';
if (validateSettings(settings)) { if (validateSettings(settings, provider)) {
endpoints.push({ endpoints.push({
...metadata, ...metadata,
provider,
settings settings
}); });
} }
@@ -315,13 +321,13 @@ export function toggleEndpointEnabled(endpointId: string, enabled: boolean): Set
const endpoint = loadEndpointSettings(endpointId); const endpoint = loadEndpointSettings(endpointId);
const userTags = endpoint?.settings.tags || []; const userTags = endpoint?.settings.tags || [];
const tags = [...new Set([...userTags, 'cli-wrapper'])]; // Dedupe and ensure cli-wrapper tag const tags = [...new Set([...userTags, 'cli-wrapper'])]; // Dedupe and ensure cli-wrapper tag
addClaudeCustomEndpoint(projectDir, { addCustomEndpoint(projectDir, {
id: endpointId, id: endpointId,
name: metadata.name, name: metadata.name,
enabled: enabled, enabled: enabled,
tags, tags,
availableModels: endpoint?.settings.availableModels, availableModels: endpoint?.settings.availableModels,
settingsFile: endpoint?.settings.settingsFile settingsFile: endpoint?.settings && 'settingsFile' in endpoint.settings ? (endpoint.settings as any).settingsFile : undefined
}); });
console.log(`[CliSettings] Synced endpoint ${endpointId} enabled=${enabled} to cli-tools.json tools`); console.log(`[CliSettings] Synced endpoint ${endpointId} enabled=${enabled} to cli-tools.json tools`);
} catch (syncError) { } catch (syncError) {
@@ -368,9 +374,8 @@ export function createSettingsFromProvider(provider: {
name?: string; name?: string;
}, options?: { }, options?: {
model?: string; model?: string;
includeCoAuthoredBy?: boolean; }): CliSettings {
}): ClaudeCliSettings { const settings = createDefaultSettings('claude');
const settings = createDefaultSettings();
// Map provider credentials to env // Map provider credentials to env
if (provider.apiKey) { if (provider.apiKey) {
@@ -384,9 +389,6 @@ export function createSettingsFromProvider(provider: {
if (options?.model) { if (options?.model) {
settings.model = options.model; settings.model = options.model;
} }
if (options?.includeCoAuthoredBy !== undefined) {
settings.includeCoAuthoredBy = options.includeCoAuthoredBy;
}
return settings; return settings;
} }

View File

@@ -56,8 +56,8 @@ export async function handleCliSettingsRoutes(ctx: RouteContext): Promise<boolea
if (!request.settings || !request.settings.env) { if (!request.settings || !request.settings.env) {
return { error: 'settings.env is required', status: 400 }; return { error: 'settings.env is required', status: 400 };
} }
// Deep validation of settings object // Deep validation of settings object (provider-aware)
if (!validateSettings(request.settings)) { if (!validateSettings(request.settings, request.provider)) {
return { error: 'Invalid settings object format', status: 400 }; return { error: 'Invalid settings object format', status: 400 };
} }

View File

@@ -15,6 +15,8 @@ import { getCliSessionPolicy } from './cli-session-policy.js';
import { appendCliSessionAudit } from './cli-session-audit.js'; import { appendCliSessionAudit } from './cli-session-audit.js';
import { getLaunchConfig } from './cli-launch-registry.js'; import { getLaunchConfig } from './cli-launch-registry.js';
import { assembleInstruction, type InstructionType } from './cli-instruction-assembler.js'; import { assembleInstruction, type InstructionType } from './cli-instruction-assembler.js';
import { loadEndpointSettings } from '../../config/cli-settings-manager.js';
import { getToolConfig } from '../../tools/claude-cli-tools.js';
export interface CliSession { export interface CliSession {
sessionKey: string; sessionKey: string;
@@ -41,6 +43,8 @@ export interface CreateCliSessionOptions {
resumeKey?: string; resumeKey?: string;
/** Launch mode for native CLI sessions. */ /** Launch mode for native CLI sessions. */
launchMode?: 'default' | 'yolo'; launchMode?: 'default' | 'yolo';
/** Settings endpoint ID for injecting env vars and settings into CLI process. */
settingsEndpointId?: string;
} }
export interface ExecuteInCliSessionOptions { export interface ExecuteInCliSessionOptions {
@@ -221,15 +225,56 @@ export class CliSessionManager {
let args: string[]; let args: string[];
let cliTool: string | undefined; let cliTool: string | undefined;
// Load settings endpoint env vars and extra args if specified
let endpointEnv: Record<string, string> = {};
let endpointExtraArgs: string[] = [];
if (options.settingsEndpointId) {
try {
const endpoint = loadEndpointSettings(options.settingsEndpointId);
if (endpoint) {
// Merge env vars (skip undefined/empty values)
for (const [key, value] of Object.entries(endpoint.settings.env)) {
if (value !== undefined && value !== '') {
endpointEnv[key] = value;
}
}
// Provider-specific argument injection
const provider = endpoint.provider || 'claude';
if (provider === 'claude' && 'settingsFile' in endpoint.settings && endpoint.settings.settingsFile) {
endpointExtraArgs.push('--settings', endpoint.settings.settingsFile);
} else if (provider === 'codex' && 'profile' in endpoint.settings && endpoint.settings.profile) {
endpointExtraArgs.push('--profile', endpoint.settings.profile);
}
// Gemini: env vars only, no extra CLI flags
}
} catch (err) {
console.warn('[CliSessionManager] Failed to load settings endpoint:', options.settingsEndpointId, err);
}
} else if (options.tool) {
// Fallback: read settingsFile from cli-tools.json for the tool
try {
const toolConfig = getToolConfig(this.projectRoot, options.tool);
if (options.tool === 'claude' && toolConfig.settingsFile) {
endpointExtraArgs.push('--settings', toolConfig.settingsFile);
}
} catch (err) {
// Non-fatal: continue without settings file
}
}
if (options.tool) { if (options.tool) {
// Native CLI interactive session: spawn the CLI process directly // Native CLI interactive session: spawn the CLI process directly
const launchMode = options.launchMode ?? 'default'; const launchMode = options.launchMode ?? 'default';
const config = getLaunchConfig(options.tool, launchMode); const config = getLaunchConfig(options.tool, launchMode);
cliTool = options.tool; cliTool = options.tool;
// Append endpoint-specific extra args (e.g., --settings for claude)
const allArgs = [...config.args, ...endpointExtraArgs];
// Build the full command string with arguments // Build the full command string with arguments
const fullCommand = config.args.length > 0 const fullCommand = allArgs.length > 0
? `${config.command} ${config.args.join(' ')}` ? `${config.command} ${allArgs.join(' ')}`
: config.command; : config.command;
// On Windows, CLI tools installed via npm are typically .cmd files. // On Windows, CLI tools installed via npm are typically .cmd files.
@@ -275,7 +320,7 @@ export class CliSessionManager {
// Unix: direct spawn works for most CLI tools // Unix: direct spawn works for most CLI tools
shellKind = 'git-bash'; shellKind = 'git-bash';
file = config.command; file = config.command;
args = config.args; args = allArgs;
} }
} else { } else {
@@ -289,6 +334,12 @@ export class CliSessionManager {
args = picked.args; args = picked.args;
} }
// Merge endpoint env vars with process.env (endpoint overrides process.env)
const spawnEnv: Record<string, string> = {
...(process.env as Record<string, string>),
...endpointEnv,
};
let pty: nodePty.IPty; let pty: nodePty.IPty;
try { try {
pty = nodePty.spawn(file, args, { pty = nodePty.spawn(file, args, {
@@ -296,7 +347,7 @@ export class CliSessionManager {
cols: options.cols ?? 120, cols: options.cols ?? 120,
rows: options.rows ?? 30, rows: options.rows ?? 30,
cwd: workingDir, cwd: workingDir,
env: process.env as Record<string, string> env: spawnEnv
}); });
} catch (spawnError: unknown) { } catch (spawnError: unknown) {
const errorMsg = spawnError instanceof Error ? spawnError.message : String(spawnError); const errorMsg = spawnError instanceof Error ? spawnError.message : String(spawnError);

View File

@@ -859,12 +859,12 @@ export function removeClaudeApiEndpoint(
} }
/** /**
* @deprecated Use addClaudeApiEndpoint instead * Add a custom CLI settings endpoint tool to cli-tools.json
* Adds tool to config based on tags: * Adds tool based on tags:
* - cli-wrapper tag -> type: 'cli-wrapper' * - cli-wrapper tag -> type: 'cli-wrapper'
* - others -> type: 'api-endpoint' * - others -> type: 'api-endpoint'
*/ */
export function addClaudeCustomEndpoint( export function addCustomEndpoint(
projectDir: string, projectDir: string,
endpoint: { id: string; name: string; enabled: boolean; tags?: string[]; availableModels?: string[]; settingsFile?: string } endpoint: { id: string; name: string; enabled: boolean; tags?: string[]; availableModels?: string[]; settingsFile?: string }
): ClaudeCliToolsConfig { ): ClaudeCliToolsConfig {
@@ -895,10 +895,13 @@ export function addClaudeCustomEndpoint(
return config; return config;
} }
/** @deprecated Use addCustomEndpoint instead */
export const addClaudeCustomEndpoint = addCustomEndpoint;
/** /**
* Remove endpoint tool (cli-wrapper or api-endpoint) * Remove endpoint tool (cli-wrapper or api-endpoint)
*/ */
export function removeClaudeCustomEndpoint( export function removeCustomEndpoint(
projectDir: string, projectDir: string,
endpointId: string endpointId: string
): ClaudeCliToolsConfig { ): ClaudeCliToolsConfig {
@@ -918,6 +921,9 @@ export function removeClaudeCustomEndpoint(
return config; return config;
} }
/** @deprecated Use removeCustomEndpoint instead */
export const removeClaudeCustomEndpoint = removeCustomEndpoint;
/** /**
* Get config source info * Get config source info
*/ */

View File

@@ -1,8 +1,13 @@
/** /**
* CLI Settings Type Definitions * CLI Settings Type Definitions
* Supports Claude CLI --settings parameter format * Supports multi-provider CLI settings: Claude, Codex, Gemini
*/ */
/**
* CLI Provider type discriminator
*/
export type CliProvider = 'claude' | 'codex' | 'gemini';
/** /**
* Claude CLI Settings 文件格式 * Claude CLI Settings 文件格式
* 对应 `claude --settings <file-or-json>` 参数 * 对应 `claude --settings <file-or-json>` 参数
@@ -21,8 +26,6 @@ export interface ClaudeCliSettings {
}; };
/** 模型选择 */ /** 模型选择 */
model?: 'opus' | 'sonnet' | 'haiku' | string; model?: 'opus' | 'sonnet' | 'haiku' | string;
/** 是否包含 co-authored-by */
includeCoAuthoredBy?: boolean;
/** CLI工具标签 (用于标签路由) */ /** CLI工具标签 (用于标签路由) */
tags?: string[]; tags?: string[];
/** 可用模型列表 (显示在下拉菜单中) */ /** 可用模型列表 (显示在下拉菜单中) */
@@ -31,6 +34,60 @@ export interface ClaudeCliSettings {
settingsFile?: string; settingsFile?: string;
} }
/**
* Codex CLI Settings
* Codex 使用 --profile 传递配置, auth.json / config.toml 管理凭证和设置
*/
export interface CodexCliSettings {
/** 环境变量配置 */
env: {
/** OpenAI API Key */
OPENAI_API_KEY?: string;
/** OpenAI API Base URL */
OPENAI_BASE_URL?: string;
/** 其他自定义环境变量 */
[key: string]: string | undefined;
};
/** Codex profile 名称 (传递为 --profile <name>) */
profile?: string;
/** 模型选择 */
model?: string;
/** auth.json 内容 (JSON 字符串) */
authJson?: string;
/** config.toml 内容 (TOML 字符串) */
configToml?: string;
/** CLI工具标签 */
tags?: string[];
/** 可用模型列表 */
availableModels?: string[];
}
/**
* Gemini CLI Settings
*/
export interface GeminiCliSettings {
/** 环境变量配置 */
env: {
/** Gemini API Key */
GEMINI_API_KEY?: string;
/** Google API Key (alternative) */
GOOGLE_API_KEY?: string;
/** 其他自定义环境变量 */
[key: string]: string | undefined;
};
/** 模型选择 */
model?: string;
/** CLI工具标签 */
tags?: string[];
/** 可用模型列表 */
availableModels?: string[];
}
/**
* Union type for all provider settings
*/
export type CliSettings = ClaudeCliSettings | CodexCliSettings | GeminiCliSettings;
/** /**
* 端点 Settings 配置(带元数据) * 端点 Settings 配置(带元数据)
*/ */
@@ -41,8 +98,10 @@ export interface EndpointSettings {
name: string; name: string;
/** 端点描述 */ /** 端点描述 */
description?: string; description?: string;
/** Claude CLI Settings */ /** CLI provider 类型 (默认 'claude' 兼容旧数据) */
settings: ClaudeCliSettings; provider: CliProvider;
/** CLI Settings (provider-specific) */
settings: CliSettings;
/** 是否启用 */ /** 是否启用 */
enabled: boolean; enabled: boolean;
/** 创建时间 */ /** 创建时间 */
@@ -76,7 +135,9 @@ export interface SaveEndpointRequest {
id?: string; id?: string;
name: string; name: string;
description?: string; description?: string;
settings: ClaudeCliSettings; /** CLI provider 类型 */
provider?: CliProvider;
settings: CliSettings;
enabled?: boolean; enabled?: boolean;
} }
@@ -104,22 +165,39 @@ export function mapProviderToClaudeEnv(provider: {
/** /**
* 创建默认 Settings * 创建默认 Settings
*/ */
export function createDefaultSettings(): ClaudeCliSettings { export function createDefaultSettings(provider: CliProvider = 'claude'): CliSettings {
switch (provider) {
case 'codex':
return {
env: {},
model: '',
tags: [],
availableModels: []
} satisfies CodexCliSettings;
case 'gemini':
return {
env: {},
model: '',
tags: [],
availableModels: []
} satisfies GeminiCliSettings;
case 'claude':
default:
return { return {
env: { env: {
DISABLE_AUTOUPDATER: '1' DISABLE_AUTOUPDATER: '1'
}, },
model: 'sonnet', model: 'sonnet',
includeCoAuthoredBy: false,
tags: [], tags: [],
availableModels: [] availableModels: []
}; } satisfies ClaudeCliSettings;
}
} }
/** /**
* 验证 Settings 格式 * 验证 Settings 格式 (provider-aware)
*/ */
export function validateSettings(settings: unknown): settings is ClaudeCliSettings { export function validateSettings(settings: unknown, provider?: CliProvider): settings is CliSettings {
if (!settings || typeof settings !== 'object') { if (!settings || typeof settings !== 'object') {
return false; return false;
} }
@@ -136,7 +214,6 @@ export function validateSettings(settings: unknown): settings is ClaudeCliSettin
for (const key in envObj) { for (const key in envObj) {
if (Object.prototype.hasOwnProperty.call(envObj, key)) { if (Object.prototype.hasOwnProperty.call(envObj, key)) {
const value = envObj[key]; const value = envObj[key];
// 允许 undefined 或 string其他类型包括 null都拒绝
if (value !== undefined && typeof value !== 'string') { if (value !== undefined && typeof value !== 'string') {
return false; return false;
} }
@@ -148,11 +225,6 @@ export function validateSettings(settings: unknown): settings is ClaudeCliSettin
return false; return false;
} }
// includeCoAuthoredBy 可选,但如果存在必须是布尔值
if (s.includeCoAuthoredBy !== undefined && typeof s.includeCoAuthoredBy !== 'boolean') {
return false;
}
// tags 可选,但如果存在必须是数组 // tags 可选,但如果存在必须是数组
if (s.tags !== undefined && !Array.isArray(s.tags)) { if (s.tags !== undefined && !Array.isArray(s.tags)) {
return false; return false;
@@ -163,10 +235,26 @@ export function validateSettings(settings: unknown): settings is ClaudeCliSettin
return false; return false;
} }
// Provider-specific validation
if (provider === 'codex') {
// profile 可选,但如果存在必须是字符串
if (s.profile !== undefined && typeof s.profile !== 'string') {
return false;
}
// authJson 可选,但如果存在必须是字符串
if (s.authJson !== undefined && typeof s.authJson !== 'string') {
return false;
}
// configToml 可选,但如果存在必须是字符串
if (s.configToml !== undefined && typeof s.configToml !== 'string') {
return false;
}
} else if (provider === 'claude' || !provider) {
// settingsFile 可选,但如果存在必须是字符串 // settingsFile 可选,但如果存在必须是字符串
if (s.settingsFile !== undefined && typeof s.settingsFile !== 'string') { if (s.settingsFile !== undefined && typeof s.settingsFile !== 'string') {
return false; return false;
} }
}
return true; return true;
} }

View File

@@ -17,13 +17,15 @@ import {
} from '../../dist/types/cli-settings.js'; } from '../../dist/types/cli-settings.js';
// Type for testing (interfaces are erased in JS) // Type for testing (interfaces are erased in JS)
type ClaudeCliSettings = { type CliSettings = {
env: Record<string, string | undefined>; env: Record<string, string | undefined>;
model?: string; model?: string;
includeCoAuthoredBy?: boolean;
tags?: string[]; tags?: string[];
availableModels?: string[]; availableModels?: string[];
settingsFile?: string; settingsFile?: string;
profile?: string;
authJson?: string;
configToml?: string;
}; };
describe('cli-settings.ts', () => { describe('cli-settings.ts', () => {
@@ -37,7 +39,6 @@ describe('cli-settings.ts', () => {
DISABLE_AUTOUPDATER: '1', DISABLE_AUTOUPDATER: '1',
}, },
model: 'sonnet', model: 'sonnet',
includeCoAuthoredBy: true,
tags: ['分析', 'Debug'], tags: ['分析', 'Debug'],
availableModels: ['opus', 'sonnet', 'haiku'], availableModels: ['opus', 'sonnet', 'haiku'],
settingsFile: '/path/to/settings.json', settingsFile: '/path/to/settings.json',
@@ -241,23 +242,72 @@ describe('cli-settings.ts', () => {
}); });
}); });
describe('should validate includeCoAuthoredBy field', () => { describe('should validate codex-specific fields', () => {
it('should accept boolean includeCoAuthoredBy', () => { it('should accept valid codex settings with profile', () => {
const settings = { const settings = {
env: {}, env: { OPENAI_API_KEY: 'sk-test' },
includeCoAuthoredBy: true, model: 'gpt-5.2',
profile: 'default',
}; };
assert.strictEqual(validateSettings(settings), true); assert.strictEqual(validateSettings(settings, 'codex'), true);
}); });
it('should reject non-boolean includeCoAuthoredBy', () => { it('should reject non-string profile for codex', () => {
const settings = { const settings = {
env: {}, env: {},
includeCoAuthoredBy: 'true', profile: 123,
}; };
assert.strictEqual(validateSettings(settings), false); assert.strictEqual(validateSettings(settings, 'codex'), false);
});
it('should accept codex settings with authJson and configToml', () => {
const settings = {
env: { OPENAI_API_KEY: 'sk-test' },
authJson: '{"OPENAI_API_KEY": "sk-test"}',
configToml: 'model = "gpt-5.2"',
};
assert.strictEqual(validateSettings(settings, 'codex'), true);
});
});
describe('should validate gemini-specific fields', () => {
it('should accept valid gemini settings', () => {
const settings = {
env: { GEMINI_API_KEY: 'AIza-test' },
model: 'gemini-2.5-flash',
};
assert.strictEqual(validateSettings(settings, 'gemini'), true);
});
it('should accept gemini settings with GOOGLE_API_KEY', () => {
const settings = {
env: { GOOGLE_API_KEY: 'AIza-test' },
model: 'gemini-2.5-pro',
tags: ['分析'],
availableModels: ['gemini-2.5-flash', 'gemini-2.5-pro'],
};
assert.strictEqual(validateSettings(settings, 'gemini'), true);
});
it('should accept gemini settings with empty env', () => {
const settings = {
env: {},
};
assert.strictEqual(validateSettings(settings, 'gemini'), true);
});
it('should reject gemini settings with non-string env value', () => {
const settings = {
env: { GEMINI_API_KEY: 12345 },
};
assert.strictEqual(validateSettings(settings, 'gemini'), false);
}); });
}); });
@@ -358,7 +408,6 @@ describe('cli-settings.ts', () => {
CUSTOM_VAR: 'custom-value', CUSTOM_VAR: 'custom-value',
}, },
model: 'custom-model', model: 'custom-model',
includeCoAuthoredBy: false,
tags: [], tags: [],
availableModels: [], availableModels: [],
settingsFile: '/path/to/settings.json', settingsFile: '/path/to/settings.json',
@@ -436,53 +485,59 @@ describe('cli-settings.ts', () => {
}); });
describe('createDefaultSettings', () => { describe('createDefaultSettings', () => {
it('should create valid default settings', () => { it('should create valid default claude settings', () => {
const settings = createDefaultSettings(); const settings = createDefaultSettings();
assert.strictEqual(validateSettings(settings), true); assert.strictEqual(validateSettings(settings), true);
}); });
it('should include all default fields', () => { it('should create valid default codex settings', () => {
const settings = createDefaultSettings('codex');
assert.strictEqual(validateSettings(settings, 'codex'), true);
});
it('should create valid default gemini settings', () => {
const settings = createDefaultSettings('gemini');
assert.strictEqual(validateSettings(settings, 'gemini'), true);
});
it('should include all default fields for claude', () => {
const settings = createDefaultSettings(); const settings = createDefaultSettings();
assert.ok('env' in settings); assert.ok('env' in settings);
assert.ok('model' in settings); assert.ok('model' in settings);
assert.ok('includeCoAuthoredBy' in settings);
assert.ok('tags' in settings); assert.ok('tags' in settings);
assert.ok('availableModels' in settings); assert.ok('availableModels' in settings);
}); });
it('should have correct default values', () => { it('should have correct default values for claude', () => {
const settings = createDefaultSettings(); const settings = createDefaultSettings();
assert.deepStrictEqual(settings.env, { assert.deepStrictEqual(settings.env, {
DISABLE_AUTOUPDATER: '1', DISABLE_AUTOUPDATER: '1',
}); });
assert.strictEqual(settings.model, 'sonnet'); assert.strictEqual(settings.model, 'sonnet');
assert.strictEqual(settings.includeCoAuthoredBy, false);
assert.deepStrictEqual(settings.tags, []); assert.deepStrictEqual(settings.tags, []);
assert.deepStrictEqual(settings.availableModels, []); assert.deepStrictEqual(settings.availableModels, []);
}); });
}); });
describe('TypeScript type safety', () => { describe('TypeScript type safety', () => {
it('should enforce ClaudeCliSettings interface structure', () => { it('should enforce CliSettings interface structure', () => {
// This test verifies TypeScript compilation catches type errors
const validSettings = { const validSettings = {
env: { env: {
ANTHROPIC_AUTH_TOKEN: 'sk-ant-123', ANTHROPIC_AUTH_TOKEN: 'sk-ant-123',
}, },
model: 'opus', model: 'opus',
includeCoAuthoredBy: true,
tags: ['tag1'], tags: ['tag1'],
availableModels: ['model1'], availableModels: ['model1'],
settingsFile: '/path/to/file', settingsFile: '/path/to/file',
}; };
// Type assertion: all fields should be present and of correct type
assert.strictEqual(typeof validSettings.env, 'object'); assert.strictEqual(typeof validSettings.env, 'object');
assert.strictEqual(typeof validSettings.model, 'string'); assert.strictEqual(typeof validSettings.model, 'string');
assert.strictEqual(typeof validSettings.includeCoAuthoredBy, 'boolean');
assert.strictEqual(Array.isArray(validSettings.tags), true); assert.strictEqual(Array.isArray(validSettings.tags), true);
assert.strictEqual(Array.isArray(validSettings.availableModels), true); assert.strictEqual(Array.isArray(validSettings.availableModels), true);
assert.strictEqual(typeof validSettings.settingsFile, 'string'); assert.strictEqual(typeof validSettings.settingsFile, 'string');