From d6acbaf30fd465652424f0ff36da4a7554f7632e Mon Sep 17 00:00:00 2001 From: catlog22 Date: Wed, 25 Feb 2026 17:40:43 +0800 Subject: [PATCH] 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 --- .../api-settings/CliSettingsList.tsx | 73 +- .../api-settings/CliSettingsModal.tsx | 777 ++++++++++-------- .../terminal-dashboard/CliConfigModal.tsx | 131 ++- ccw/frontend/src/hooks/useApiSettings.ts | 20 +- ccw/frontend/src/lib/api.ts | 71 +- ccw/src/config/cli-settings-manager.ts | 34 +- ccw/src/core/routes/cli-settings-routes.ts | 4 +- ccw/src/core/services/cli-session-manager.ts | 59 +- ccw/src/tools/claude-cli-tools.ts | 14 +- ccw/src/types/cli-settings.ts | 142 +++- ccw/tests/types/cli-settings.test.ts | 99 ++- 11 files changed, 927 insertions(+), 497 deletions(-) diff --git a/ccw/frontend/src/components/api-settings/CliSettingsList.tsx b/ccw/frontend/src/components/api-settings/CliSettingsList.tsx index f1f7476f..e41c5ae7 100644 --- a/ccw/frontend/src/components/api-settings/CliSettingsList.tsx +++ b/ccw/frontend/src/components/api-settings/CliSettingsList.tsx @@ -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 ( - - {formatMessage({ id: 'apiSettings.cliSettings.providerBased' })} - - ); - } + // Display provider badge + const getProviderBadge = () => { + const provider = cliSettings.provider || 'claude'; + const variants: Record = { + claude: { variant: 'secondary', label: 'Claude' }, + codex: { variant: 'outline', label: 'Codex' }, + gemini: { variant: 'default', label: 'Gemini' }, + }; + const config = variants[provider] || variants.claude; return ( - - {formatMessage({ id: 'apiSettings.cliSettings.direct' })} + + {config.label} ); }; @@ -91,6 +86,12 @@ function CliSettingsCard({ return {formatMessage({ id: 'apiSettings.common.enabled' })}; }; + // Get provider-appropriate endpoint URL for display + const endpointUrl = (() => { + const env = cliSettings.settings.env; + return env.ANTHROPIC_BASE_URL || env.OPENAI_BASE_URL || ''; + })(); + return (
@@ -99,7 +100,7 @@ function CliSettingsCard({

{cliSettings.name}

{getStatusBadge()} - {getModeBadge()} + {getProviderBadge()}
{cliSettings.description && (

{cliSettings.description}

@@ -107,17 +108,12 @@ function CliSettingsCard({
- {cliSettings.settings.model || 'sonnet'} + {cliSettings.settings.model || 'default'} - {cliSettings.settings.env.ANTHROPIC_BASE_URL && ( - + {endpointUrl && ( + - {cliSettings.settings.env.ANTHROPIC_BASE_URL} - - )} - {cliSettings.settings.includeCoAuthoredBy !== undefined && ( - - {formatMessage({ id: 'apiSettings.cliSettings.coAuthoredBy' })}: {formatMessage({ id: cliSettings.settings.includeCoAuthoredBy ? 'common.yes' : 'common.no' })} + {endpointUrl} )}
@@ -168,8 +164,7 @@ export function CliSettingsList({ cliSettings, totalCount, enabledCount, - providerBasedCount, - directCount, + providerCounts, isLoading, refetch, } = useCliSettings(); @@ -219,7 +214,7 @@ export function CliSettingsList({ return (
{/* Stats Cards */} -
+
@@ -240,21 +235,21 @@ export function CliSettingsList({
- - {providerBasedCount} + {providerCounts.claude || 0}
-

- {formatMessage({ id: 'apiSettings.cliSettings.providerBased' })} -

+

Claude

- - {directCount} + {providerCounts.codex || 0}
-

- {formatMessage({ id: 'apiSettings.cliSettings.direct' })} -

+

Codex

+
+ +
+ {providerCounts.gemini || 0} +
+

Gemini

diff --git a/ccw/frontend/src/components/api-settings/CliSettingsModal.tsx b/ccw/frontend/src/components/api-settings/CliSettingsModal.tsx index f067ef8e..672a145e 100644 --- a/ccw/frontend/src/components/api-settings/CliSettingsModal.tsx +++ b/ccw/frontend/src/components/api-settings/CliSettingsModal.tsx @@ -1,7 +1,7 @@ // ======================================== // 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 { 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 { useCreateCliSettings, useUpdateCliSettings, useProviders } from '@/hooks/useApiSettings'; import { useNotifications } from '@/hooks/useNotifications'; -import type { CliSettingsEndpoint } from '@/lib/api'; +import type { CliSettingsEndpoint, CliProvider } from '@/lib/api'; // ========== Types ========== @@ -31,34 +31,39 @@ export interface CliSettingsModalProps { open: boolean; onClose: () => void; cliSettings?: CliSettingsEndpoint | null; + /** Pre-selected provider when creating new */ + defaultProvider?: CliProvider; } type ModeType = 'provider-based' | 'direct'; // ========== Helper Functions ========== -function parseConfigJson( - configJson: string -): { ok: true; value: Record } | { ok: false; errorKey: string } { - const trimmed = configJson.trim(); +function parseConfigText( + text: string, + format: 'json' | 'toml' = 'json' +): { ok: true; value: string } | { ok: false; errorKey: string } { + const trimmed = text.trim(); if (!trimmed) { - return { ok: true, value: {} }; + return { ok: true, value: '' }; } - try { - const parsed = JSON.parse(trimmed) as unknown; - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return { ok: false, errorKey: 'configMustBeObject' }; + if (format === 'json') { + try { + JSON.parse(trimmed); + return { ok: true, value: trimmed }; + } catch { + return { ok: false, errorKey: 'invalidJson' }; } - return { ok: true, value: parsed as Record }; - } catch { - return { ok: false, errorKey: 'invalidJson' }; } + + // TOML: basic format check (no full parser needed) + return { ok: true, value: trimmed }; } // ========== Main Component ========== -export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModalProps) { +export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }: CliSettingsModalProps) { const { formatMessage } = useIntl(); const { error } = useNotifications(); const isEditing = !!cliSettings; @@ -67,83 +72,105 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa const { createCliSettings, isCreating } = useCreateCliSettings(); const { updateCliSettings, isUpdating } = useUpdateCliSettings(); - // Get providers for provider-based mode + // Get providers for provider-based mode (Claude) const { providers } = useProviders(); - // Form state + // Common form state const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [enabled, setEnabled] = useState(true); + const [cliProvider, setCliProvider] = useState('claude'); + + // Claude: mode tabs const [mode, setMode] = useState('direct'); - - // Provider-based mode state 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 [baseUrl, setBaseUrl] = useState(''); 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([]); const [modelInput, setModelInput] = useState(''); - - // Tags state const [tags, setTags] = useState([]); const [tagInput, setTagInput] = useState(''); - - // JSON config state const [configJson, setConfigJson] = useState('{}'); const [showJsonInput, setShowJsonInput] = useState(false); - - // Validation errors const [errors, setErrors] = useState>({}); - // Initialize form from cliSettings + // Initialize form useEffect(() => { if (cliSettings) { setName(cliSettings.name); setDescription(cliSettings.description || ''); setEnabled(cliSettings.enabled); - setModel(cliSettings.settings.model || 'sonnet'); - setIncludeCoAuthoredBy(cliSettings.settings.includeCoAuthoredBy || false); - setSettingsFile(cliSettings.settings.settingsFile || ''); + setCliProvider(cliSettings.provider || 'claude'); + setModel(cliSettings.settings.model || ''); setAvailableModels(cliSettings.settings.availableModels || []); setTags(cliSettings.settings.tags || []); - // Determine mode based on settings - const hasCustomBaseUrl = Boolean( - cliSettings.settings.env.ANTHROPIC_BASE_URL && - !cliSettings.settings.env.ANTHROPIC_BASE_URL.includes('api.anthropic.com') - ); - - if (hasCustomBaseUrl) { - setMode('direct'); - setBaseUrl(cliSettings.settings.env.ANTHROPIC_BASE_URL || ''); - setAuthToken(cliSettings.settings.env.ANTHROPIC_AUTH_TOKEN || ''); - } else { - setMode('provider-based'); - // Try to find matching provider - const matchingProvider = providers.find((p) => p.apiBase === cliSettings.settings.env.ANTHROPIC_BASE_URL); - if (matchingProvider) { - setProviderId(matchingProvider.id); + const provider = cliSettings.provider || 'claude'; + if (provider === 'claude') { + setSettingsFile((cliSettings.settings as any).settingsFile || ''); + const env = cliSettings.settings.env; + const hasCustomBaseUrl = Boolean( + env.ANTHROPIC_BASE_URL && !env.ANTHROPIC_BASE_URL.includes('api.anthropic.com') + ); + if (hasCustomBaseUrl) { + setMode('direct'); + setBaseUrl(env.ANTHROPIC_BASE_URL || ''); + setAuthToken(env.ANTHROPIC_AUTH_TOKEN || ''); + } else { + setMode('provider-based'); + const matchingProvider = providers.find((p) => p.apiBase === env.ANTHROPIC_BASE_URL); + 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 { - // Reset form for new CLI settings + // Reset for new + const p = defaultProvider || 'claude'; setName(''); setDescription(''); setEnabled(true); + setCliProvider(p); setMode('direct'); setProviderId(''); - setModel('sonnet'); - setIncludeCoAuthoredBy(false); + setModel(p === 'claude' ? 'sonnet' : ''); setSettingsFile(''); setAuthToken(''); setBaseUrl(''); + setCodexApiKey(''); + setCodexBaseUrl(''); + setCodexProfile(''); + setShowCodexKey(false); + setAuthJson(''); + setConfigToml(''); + setWriteCommonConfig(false); + setGeminiApiKey(''); + setShowGeminiKey(false); setAvailableModels([]); setModelInput(''); setTags([]); @@ -152,7 +179,7 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa setShowJsonInput(false); setErrors({}); } - }, [cliSettings, open, providers]); + }, [cliSettings, open, providers, defaultProvider]); // Validate form const validateForm = (): boolean => { @@ -161,33 +188,35 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa if (!name.trim()) { newErrors.name = formatMessage({ id: 'apiSettings.validation.nameRequired' }); } else { - // Validate name format: must start with letter, followed by letters/numbers/hyphens/underscores const namePattern = /^[a-zA-Z][a-zA-Z0-9_-]*$/; if (!namePattern.test(name.trim())) { newErrors.name = formatMessage({ id: 'apiSettings.cliSettings.nameFormatHint' }); } - // Validate name length if (name.trim().length > 32) { newErrors.name = formatMessage({ id: 'apiSettings.cliSettings.nameTooLong' }, { max: 32 }); } } - if (mode === 'provider-based') { - if (!providerId) { + if (cliProvider === 'claude') { + if (mode === 'provider-based' && !providerId) { newErrors.providerId = formatMessage({ id: 'apiSettings.cliSettings.validation.providerRequired' }); } - } else { - // Direct mode - if (!authToken.trim() && !baseUrl.trim()) { + if (mode === 'direct' && !authToken.trim() && !baseUrl.trim()) { 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) { - const parsedConfig = parseConfigJson(configJson); - if (!parsedConfig.ok) { - newErrors.configJson = formatMessage({ id: `apiSettings.cliSettings.${parsedConfig.errorKey}` }); + const parsed = parseConfigText(configJson, 'json'); + if (!parsed.ok) { + newErrors.configJson = formatMessage({ id: `apiSettings.cliSettings.${parsed.errorKey}` }); } } @@ -200,38 +229,59 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa if (!validateForm()) return; try { - // Build settings object based on mode - const env: Record = { - DISABLE_AUTOUPDATER: '1', - }; + let settings: any; - if (mode === 'provider-based') { - // Provider-based mode: get settings from selected provider - const provider = providers.find((p) => p.id === providerId); - if (provider) { - if (provider.apiBase) { - env.ANTHROPIC_BASE_URL = provider.apiBase; - } - if (provider.apiKey) { - env.ANTHROPIC_AUTH_TOKEN = provider.apiKey; - } + if (cliProvider === 'claude') { + const env: Record = { DISABLE_AUTOUPDATER: '1' }; + if (mode === 'provider-based') { + const provider = providers.find((p) => p.id === providerId); + if (provider?.apiBase) env.ANTHROPIC_BASE_URL = provider.apiBase; + if (provider?.apiKey) env.ANTHROPIC_AUTH_TOKEN = provider.apiKey; + } else { + if (authToken.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 = {}; + 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 { - // Direct mode: use manual input - if (authToken.trim()) { - env.ANTHROPIC_AUTH_TOKEN = authToken.trim(); - } - if (baseUrl.trim()) { - env.ANTHROPIC_BASE_URL = baseUrl.trim(); - } + // gemini + const env: Record = {}; + if (geminiApiKey.trim()) env.GEMINI_API_KEY = geminiApiKey.trim(); + settings = { + env, + model: model || undefined, + availableModels, + tags, + }; } - // Parse and merge JSON config if shown - let extraSettings: Record = {}; - if (showJsonInput) { - const parsedConfig = parseConfigJson(configJson); - if (parsedConfig.ok) { - extraSettings = parsedConfig.value; + // Merge JSON config if shown + if (showJsonInput && configJson.trim()) { + try { + const extra = JSON.parse(configJson); + if (extra && typeof extra === 'object' && !Array.isArray(extra)) { + Object.assign(settings, extra); + } + } catch { + // skip invalid json } } @@ -239,16 +289,9 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa id: cliSettings?.id, name: name.trim(), description: description.trim() || undefined, + provider: cliProvider, enabled, - settings: { - env, - model, - includeCoAuthoredBy, - settingsFile: settingsFile.trim() || undefined, - availableModels, - tags, - ...extraSettings, - }, + settings, }; if (isEditing && cliSettings) { @@ -263,40 +306,34 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa } }; - // Handle add model + // Tag/Model helpers const handleAddModel = () => { - const newModel = modelInput.trim(); - if (newModel && !availableModels.includes(newModel)) { - setAvailableModels([...availableModels, newModel]); + const v = modelInput.trim(); + if (v && !availableModels.includes(v)) { + setAvailableModels([...availableModels, v]); setModelInput(''); } }; - - // Handle remove model - const handleRemoveModel = (modelToRemove: string) => { - setAvailableModels(availableModels.filter((m) => m !== modelToRemove)); - }; - - // Handle add tag + const handleRemoveModel = (m: string) => setAvailableModels(availableModels.filter((x) => x !== m)); const handleAddTag = () => { - const newTag = tagInput.trim(); - if (newTag && !tags.includes(newTag)) { - setTags([...tags, newTag]); + const v = tagInput.trim(); + if (v && !tags.includes(v)) { + setTags([...tags, v]); 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']; - - // Get selected provider info const selectedProvider = providers.find((p) => p.id === providerId); + // Title by provider + const providerLabel: Record = { + claude: 'Claude', + codex: 'Codex', + gemini: 'Gemini', + }; + return ( @@ -305,6 +342,7 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa {isEditing ? formatMessage({ id: 'apiSettings.cliSettings.actions.edit' }) : formatMessage({ id: 'apiSettings.cliSettings.actions.add' })} + {' - '}{providerLabel[cliProvider]} {formatMessage({ id: 'apiSettings.cliSettings.modalDescription' })} @@ -314,6 +352,23 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
{/* Common Fields */}
+ {/* Provider Selector (only when creating new) */} + {!isEditing && ( +
+ + +
+ )} +
- +
- {/* Mode Tabs */} - setMode(v as ModeType)} className="w-full"> - - - {formatMessage({ id: 'apiSettings.cliSettings.providerBased' })} - - - {formatMessage({ id: 'apiSettings.cliSettings.direct' })} - - + {/* ========== Claude Settings ========== */} + {cliProvider === 'claude' && ( + <> + setMode(v as ModeType)} className="w-full"> + + + {formatMessage({ id: 'apiSettings.cliSettings.providerBased' })} + + + {formatMessage({ id: 'apiSettings.cliSettings.direct' })} + + - {/* Provider-based Mode */} - -
- - - {errors.providerId &&

{errors.providerId}

} -
+ +
+ + + {errors.providerId &&

{errors.providerId}

} +
- {selectedProvider && ( -
-

{selectedProvider.name}

-

- {formatMessage({ id: 'apiSettings.common.type' })}: {selectedProvider.type} -

- {selectedProvider.apiBase && ( -

- {formatMessage({ id: 'apiSettings.providers.apiBaseUrl' })}: {selectedProvider.apiBase} -

+ {selectedProvider && ( +
+

{selectedProvider.name}

+

+ {formatMessage({ id: 'apiSettings.common.type' })}: {selectedProvider.type} +

+ {selectedProvider.apiBase && ( +

+ {formatMessage({ id: 'apiSettings.providers.apiBaseUrl' })}: {selectedProvider.apiBase} +

+ )} +
)} -
- )} +
+ + setModel(e.target.value)} placeholder="sonnet" /> +
+
+ + +
+ +
+ setAuthToken(e.target.value)} + placeholder="sk-ant-..." + className={errors.direct ? 'border-destructive pr-10' : 'pr-10'} + /> + +
+
+ +
+ + setBaseUrl(e.target.value)} + placeholder="https://api.anthropic.com" + className={errors.direct ? 'border-destructive' : ''} + /> +
+ {errors.direct &&

{errors.direct}

} + +
+ + setModel(e.target.value)} placeholder="sonnet" /> +
+
+
+ + {/* Claude: Settings File */}
- - + + setSettingsFile(e.target.value)} + placeholder={formatMessage({ id: 'apiSettings.cliSettings.settingsFilePlaceholder' })} + /> +

+ {formatMessage({ id: 'apiSettings.cliSettings.settingsFileHint' })} +

- + + )} - {/* Direct Mode */} - + {/* ========== Codex Settings ========== */} + {cliProvider === 'codex' && ( +
+ {/* API Key */}
- + +

+ 只需要填这里,下方 auth.json 会自动填充 +

setAuthToken(e.target.value)} - placeholder="sk-ant-..." - className={errors.direct ? 'border-destructive pr-10' : 'pr-10'} + id="codex-apikey" + type={showCodexKey ? 'text' : 'password'} + value={codexApiKey} + onChange={(e) => setCodexApiKey(e.target.value)} + placeholder="sk-..." + className="pr-10" /> +
+
+ + {/* API Endpoint URL */} +
+ + setCodexBaseUrl(e.target.value)} + placeholder="https://your-api-endpoint.com/v1" + /> +

+ 填写兼容 OpenAI Response 格式的服务端点地址 +

+
+ + {/* Model Name */} +
+ + setModel(e.target.value)} + placeholder="gpt-5.2" + /> +

+ 指定使用的模型,将自动更新到 config.toml 中 +

+
+ + {/* Profile */} +
+ + setCodexProfile(e.target.value)} + placeholder="default" + /> +

+ Codex profile 名称,通过 --profile 参数传递 +

+
+ + {/* auth.json Editor */} +
+ +