feat: add GitHub Copilot CLI integration to coding tools (#10403)

* feat: add GitHub Copilot CLI integration to coding tools

- Add githubCopilotCli to codeTools enum
- Support @github/copilot package installation
- Add 'copilot' executable command mapping
- Update Redux store to include GitHub Copilot CLI state
- Add GitHub Copilot CLI option to UI with proper provider mapping
- Implement environment variable handling for GitHub authentication
- Fix model selection logic to disable model choice for GitHub Copilot CLI
- Update launch validation to not require model selection for GitHub Copilot CLI
- Fix prepareLaunchEnvironment and executeLaunch to handle no-model scenario

This enables users to launch GitHub Copilot CLI directly from Cherry Studio's
code tools interface without needing to select a model, as GitHub Copilot CLI
uses GitHub's built-in models and authentication.

Signed-off-by: LeaderOnePro <leaderonepro@outlook.com>

* style: apply code formatting for GitHub Copilot CLI integration

Auto-fix code style inconsistencies using project's Biome formatter.
Resolves semicolon, comma, and quote style issues to match project standards.

Signed-off-by: LeaderOnePro <leaderonepro@outlook.com>

* feat: conditionally render model selector for GitHub Copilot CLI

- Hide model selector component when GitHub Copilot CLI is selected
- Maintain validation logic to allow GitHub Copilot CLI without model selection
- Improve UX by removing empty model dropdown for GitHub Copilot CLI

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Signed-off-by: LeaderOnePro <leaderonepro@outlook.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
LeaderOnePro 2025-09-30 23:43:19 +08:00 committed by GitHub
parent d11a2cd95c
commit 38ac42af8c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 153 additions and 58 deletions

View File

@ -217,7 +217,8 @@ export enum codeTools {
claudeCode = 'claude-code',
geminiCli = 'gemini-cli',
openaiCodex = 'openai-codex',
iFlowCli = 'iflow-cli'
iFlowCli = 'iflow-cli',
githubCopilotCli = 'github-copilot-cli'
}
export enum terminalApps {

View File

@ -31,7 +31,10 @@ interface VersionInfo {
class CodeToolsService {
private versionCache: Map<string, { version: string; timestamp: number }> = new Map()
private terminalsCache: { terminals: TerminalConfig[]; timestamp: number } | null = null
private terminalsCache: {
terminals: TerminalConfig[]
timestamp: number
} | null = null
private customTerminalPaths: Map<string, string> = new Map() // Store user-configured terminal paths
private readonly CACHE_DURATION = 1000 * 60 * 30 // 30 minutes cache
private readonly TERMINALS_CACHE_DURATION = 1000 * 60 * 5 // 5 minutes cache for terminals
@ -82,6 +85,8 @@ class CodeToolsService {
return '@qwen-code/qwen-code'
case codeTools.iFlowCli:
return '@iflow-ai/iflow-cli'
case codeTools.githubCopilotCli:
return '@github/copilot'
default:
throw new Error(`Unsupported CLI tool: ${cliTool}`)
}
@ -99,6 +104,8 @@ class CodeToolsService {
return 'qwen'
case codeTools.iFlowCli:
return 'iflow'
case codeTools.githubCopilotCli:
return 'copilot'
default:
throw new Error(`Unsupported CLI tool: ${cliTool}`)
}
@ -144,7 +151,9 @@ class CodeToolsService {
case terminalApps.powershell:
// Check for PowerShell in PATH
try {
await execAsync('powershell -Command "Get-Host"', { timeout: 3000 })
await execAsync('powershell -Command "Get-Host"', {
timeout: 3000
})
return terminal
} catch {
try {
@ -384,7 +393,9 @@ class CodeToolsService {
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : ''))
const { stdout } = await execAsync(`"${executablePath}" --version`, { timeout: 10000 })
const { stdout } = await execAsync(`"${executablePath}" --version`, {
timeout: 10000
})
// Extract version number from output (format may vary by tool)
const versionMatch = stdout.trim().match(/\d+\.\d+\.\d+/)
installedVersion = versionMatch ? versionMatch[0] : stdout.trim().split(' ')[0]
@ -425,7 +436,10 @@ class CodeToolsService {
logger.info(`${packageName} latest version: ${latestVersion}`)
// Cache the result
this.versionCache.set(cacheKey, { version: latestVersion!, timestamp: now })
this.versionCache.set(cacheKey, {
version: latestVersion!,
timestamp: now
})
logger.debug(`Cached latest version for ${packageName}`)
} catch (error) {
logger.warn(`Failed to get latest version for ${packageName}:`, error as Error)

View File

@ -108,7 +108,11 @@ export const useCodeTools = () => {
const environmentVariables = codeToolsState?.environmentVariables?.[codeToolsState.selectedCliTool] || ''
// 检查是否可以启动(所有必需字段都已填写)
const canLaunch = Boolean(codeToolsState.selectedCliTool && selectedModel && codeToolsState.currentDirectory)
const canLaunch = Boolean(
codeToolsState.selectedCliTool &&
codeToolsState.currentDirectory &&
(codeToolsState.selectedCliTool === codeTools.githubCopilotCli || selectedModel)
)
return {
// 状态

View File

@ -98,6 +98,10 @@ const CodeToolsPage: FC = () => {
return m.id.includes('openai') || OPENAI_CODEX_SUPPORTED_PROVIDERS.includes(m.provider)
}
if (selectedCliTool === codeTools.githubCopilotCli) {
return false
}
if (selectedCliTool === codeTools.qwenCode || selectedCliTool === codeTools.iFlowCli) {
if (m.supported_endpoint_types) {
return ['openai', 'openai-response'].some((type) =>
@ -196,7 +200,7 @@ const CodeToolsPage: FC = () => {
}
}
if (!selectedModel) {
if (!selectedModel && selectedCliTool !== codeTools.githubCopilotCli) {
return { isValid: false, message: t('code.model_required') }
}
@ -205,6 +209,11 @@ const CodeToolsPage: FC = () => {
// 准备启动环境
const prepareLaunchEnvironment = async (): Promise<Record<string, string> | null> => {
if (selectedCliTool === codeTools.githubCopilotCli) {
const userEnv = parseEnvironmentVariables(environmentVariables)
return userEnv
}
if (!selectedModel) return null
const modelProvider = getProviderByModel(selectedModel)
@ -229,7 +238,9 @@ const CodeToolsPage: FC = () => {
// 执行启动操作
const executeLaunch = async (env: Record<string, string>) => {
window.api.codeTools.run(selectedCliTool, selectedModel?.id!, currentDirectory, env, {
const modelId = selectedCliTool === codeTools.githubCopilotCli ? '' : selectedModel?.id!
window.api.codeTools.run(selectedCliTool, modelId, currentDirectory, env, {
autoUpdateToLatest,
terminal: selectedTerminal
})
@ -316,7 +327,12 @@ const CodeToolsPage: FC = () => {
banner
style={{ borderRadius: 'var(--list-item-border-radius)' }}
message={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<span>{t('code.bun_required_message')}</span>
<Button
type="primary"
@ -345,46 +361,64 @@ const CodeToolsPage: FC = () => {
/>
</SettingsItem>
<SettingsItem>
<div className="settings-label">
{t('code.model')}
{selectedCliTool === 'claude-code' && (
<Popover
content={
<div style={{ width: 200 }}>
<div style={{ marginBottom: 8, fontWeight: 500 }}>{t('code.supported_providers')}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{getClaudeSupportedProviders(allProviders).map((provider) => {
return (
<Link
key={provider.id}
style={{ color: 'var(--color-text)', display: 'flex', alignItems: 'center', gap: 4 }}
to={`/settings/provider?id=${provider.id}`}>
<ProviderLogo shape="square" src={getProviderLogo(provider.id)} size={20} />
{getProviderLabel(provider.id)}
<ArrowUpRight size={14} />
</Link>
)
})}
{selectedCliTool !== codeTools.githubCopilotCli && (
<SettingsItem>
<div className="settings-label">
{t('code.model')}
{selectedCliTool === 'claude-code' && (
<Popover
content={
<div style={{ width: 200 }}>
<div style={{ marginBottom: 8, fontWeight: 500 }}>{t('code.supported_providers')}</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 8
}}>
{getClaudeSupportedProviders(allProviders).map((provider) => {
return (
<Link
key={provider.id}
style={{
color: 'var(--color-text)',
display: 'flex',
alignItems: 'center',
gap: 4
}}
to={`/settings/provider?id=${provider.id}`}>
<ProviderLogo shape="square" src={getProviderLogo(provider.id)} size={20} />
{getProviderLabel(provider.id)}
<ArrowUpRight size={14} />
</Link>
)
})}
</div>
</div>
</div>
}
trigger="hover"
placement="right">
<HelpCircle size={14} style={{ color: 'var(--color-text-3)', cursor: 'pointer' }} />
</Popover>
)}
</div>
<ModelSelector
providers={availableProviders}
predicate={modelPredicate}
style={{ width: '100%' }}
placeholder={t('code.model_placeholder')}
value={selectedModel ? getModelUniqId(selectedModel) : undefined}
onChange={handleModelChange}
allowClear
/>
</SettingsItem>
}
trigger="hover"
placement="right">
<HelpCircle
size={14}
style={{
color: 'var(--color-text-3)',
cursor: 'pointer'
}}
/>
</Popover>
)}
</div>
<ModelSelector
providers={availableProviders}
predicate={modelPredicate}
style={{ width: '100%' }}
placeholder={t('code.model_placeholder')}
value={selectedModel ? getModelUniqId(selectedModel) : undefined}
onChange={handleModelChange}
allowClear
/>
</SettingsItem>
)}
<SettingsItem>
<div className="settings-label">{t('code.working_directory')}</div>
@ -403,11 +437,27 @@ const CodeToolsPage: FC = () => {
options={directories.map((dir) => ({
value: dir,
label: (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{dir}</span>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<span
style={{
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis'
}}>
{dir}
</span>
<X
size={14}
style={{ marginLeft: 8, cursor: 'pointer', color: '#999' }}
style={{
marginLeft: 8,
cursor: 'pointer',
color: '#999'
}}
onClick={(e) => handleRemoveDirectory(dir, e)}
/>
</div>
@ -429,7 +479,14 @@ const CodeToolsPage: FC = () => {
rows={2}
style={{ fontFamily: 'monospace' }}
/>
<div style={{ fontSize: 12, color: 'var(--color-text-3)', marginTop: 4 }}>{t('code.env_vars_help')}</div>
<div
style={{
fontSize: 12,
color: 'var(--color-text-3)',
marginTop: 4
}}>
{t('code.env_vars_help')}
</div>
</SettingsItem>
{/* 终端选择 (macOS 和 Windows) */}
@ -464,7 +521,12 @@ const CodeToolsPage: FC = () => {
selectedTerminal !== terminalApps.cmd &&
selectedTerminal !== terminalApps.powershell &&
selectedTerminal !== terminalApps.windowsTerminal && (
<div style={{ fontSize: 12, color: 'var(--color-text-3)', marginTop: 4 }}>
<div
style={{
fontSize: 12,
color: 'var(--color-text-3)',
marginTop: 4
}}>
{terminalCustomPaths[selectedTerminal]
? `${t('code.custom_path')}: ${terminalCustomPaths[selectedTerminal]}`
: t('code.custom_path_required')}

View File

@ -20,7 +20,8 @@ export const CLI_TOOLS = [
{ value: codeTools.qwenCode, label: 'Qwen Code' },
{ value: codeTools.geminiCli, label: 'Gemini CLI' },
{ value: codeTools.openaiCodex, label: 'OpenAI Codex' },
{ value: codeTools.iFlowCli, label: 'iFlow CLI' }
{ value: codeTools.iFlowCli, label: 'iFlow CLI' },
{ value: codeTools.githubCopilotCli, label: 'GitHub Copilot CLI' }
]
export const GEMINI_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api', 'cherryin']
@ -43,7 +44,8 @@ export const CLI_TOOL_PROVIDER_MAP: Record<string, (providers: Provider[]) => Pr
[codeTools.qwenCode]: (providers) => providers.filter((p) => p.type.includes('openai')),
[codeTools.openaiCodex]: (providers) =>
providers.filter((p) => p.id === 'openai' || OPENAI_CODEX_SUPPORTED_PROVIDERS.includes(p.id)),
[codeTools.iFlowCli]: (providers) => providers.filter((p) => p.type.includes('openai'))
[codeTools.iFlowCli]: (providers) => providers.filter((p) => p.type.includes('openai')),
[codeTools.githubCopilotCli]: () => []
}
export const getCodeToolsApiBaseUrl = (model: Model, type: EndpointType) => {
@ -158,6 +160,10 @@ export const generateToolEnvironment = ({
env.IFLOW_BASE_URL = baseUrl
env.IFLOW_MODEL_NAME = model.id
break
case codeTools.githubCopilotCli:
env.GITHUB_TOKEN = apiKey || ''
break
}
return env

View File

@ -26,12 +26,17 @@ export const initialState: CodeToolsState = {
[codeTools.qwenCode]: null,
[codeTools.claudeCode]: null,
[codeTools.geminiCli]: null,
[codeTools.openaiCodex]: null
[codeTools.openaiCodex]: null,
[codeTools.iFlowCli]: null,
[codeTools.githubCopilotCli]: null
},
environmentVariables: {
'qwen-code': '',
'claude-code': '',
'gemini-cli': ''
'gemini-cli': '',
'openai-codex': '',
'iflow-cli': '',
'github-copilot-cli': ''
},
directories: [],
currentDirectory: '',
@ -63,7 +68,10 @@ const codeToolsSlice = createSlice({
state.environmentVariables = {
'qwen-code': '',
'claude-code': '',
'gemini-cli': ''
'gemini-cli': '',
'openai-codex': '',
'iflow-cli': '',
'github-copilot-cli': ''
}
}
state.environmentVariables[state.selectedCliTool] = action.payload