重构 MCP Copilot Skill 分发流程,改为内置打包并支持手动导出

This commit is contained in:
ILoveBingLu
2026-04-08 13:18:43 +08:00
parent 74a41f8351
commit ca51c377ea
6 changed files with 56 additions and 488 deletions
-8
View File
@@ -1339,14 +1339,6 @@ function registerIpcHandlers() {
return { success: true, deleted: result.deleted, nextActiveAccountId: result.nextActiveAccountId }
})
ipcMain.handle('skillInstaller:detectTargets', async (_, skillName: string) => {
return skillInstallerService.detectTargets(skillName)
})
ipcMain.handle('skillInstaller:installSkill', async (_, skillName: string, selectedSkillsDirs?: string[]) => {
return skillInstallerService.installSkill(skillName, selectedSkillsDirs)
})
ipcMain.handle('skillInstaller:exportSkillZip', async (_, skillName: string) => {
return skillInstallerService.exportSkillZip(skillName)
})
-4
View File
@@ -1,6 +1,5 @@
import { contextBridge, ipcRenderer } from 'electron'
import type { AccountProfile } from '../src/types/account'
import type { SkillInstallTarget } from './services/skillInstallerService'
function getMcpLaunchConfigSafe(): Promise<{
command: string
@@ -47,9 +46,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
},
skillInstaller: {
detectTargets: (skillName: string) => ipcRenderer.invoke('skillInstaller:detectTargets', skillName) as Promise<SkillInstallTarget[]>,
installSkill: (skillName: string, selectedSkillsDirs?: string[]) =>
ipcRenderer.invoke('skillInstaller:installSkill', skillName, selectedSkillsDirs) as Promise<{ success: boolean; results: SkillInstallTarget[]; error?: string }>,
exportSkillZip: (skillName: string) =>
ipcRenderer.invoke('skillInstaller:exportSkillZip', skillName) as Promise<{ success: boolean; outputPath?: string; fileName?: string; version?: string; error?: string }>
},
+48 -203
View File
@@ -1,28 +1,11 @@
import { app } from 'electron'
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from 'fs'
import { homedir } from 'os'
import { dirname, join } from 'path'
import { existsSync, readFileSync } from 'fs'
import { join } from 'path'
import AdmZip from 'adm-zip'
export type SupportedAgentKind = 'codex' | 'agents'
export interface SkillInstallTarget {
agentKind: SupportedAgentKind
agentLabel: string
source: 'known' | 'discovered'
skillsDir: string
supported: boolean
installed: boolean
bundledVersion: string
installedVersion?: string
updateAvailable: boolean
installPath?: string
error?: string
}
type SkillSource = {
name: string
relativePath: string
relativePaths: string[]
}
type SkillMeta = {
@@ -34,40 +17,51 @@ type SkillMeta = {
const MANAGED_SKILLS: Record<string, SkillSource> = {
'ct-mcp-copilot': {
name: 'ct-mcp-copilot',
relativePath: join('sikll', 'ct-mcp-copilot')
relativePaths: [
'ct-mcp-copilot',
join('skills', 'ct-mcp-copilot'),
join('sikll', 'ct-mcp-copilot')
]
}
}
function getHomeDir() {
return homedir() || process.env.USERPROFILE || process.env.HOME || ''
}
function getKnownAgentTargets(): Array<{ agentKind: SupportedAgentKind; agentLabel: string; skillsDir: string; source: 'known' }> {
const home = getHomeDir()
if (!home) return []
return [
{ agentKind: 'codex', agentLabel: 'Codex', skillsDir: join(home, '.codex', 'skills'), source: 'known' },
{ agentKind: 'codex', agentLabel: 'Claude', skillsDir: join(home, '.claude', 'skills'), source: 'known' },
{ agentKind: 'agents', agentLabel: '.agents', skillsDir: join(home, '.agents', 'skills'), source: 'known' },
{ agentKind: 'agents', agentLabel: 'Cursor', skillsDir: join(home, '.cursor', 'skills'), source: 'known' },
{ agentKind: 'agents', agentLabel: 'Kiro', skillsDir: join(home, '.kiro', 'skills'), source: 'known' },
{ agentKind: 'agents', agentLabel: 'Trae', skillsDir: join(home, '.trae', 'skills'), source: 'known' },
{ agentKind: 'agents', agentLabel: 'Trae-CN', skillsDir: join(home, '.trae-cn', 'skills'), source: 'known' }
]
}
function compareVersions(a: string, b: string): number {
const aParts = a.split('.').map((x) => Number.parseInt(x, 10) || 0)
const bParts = b.split('.').map((x) => Number.parseInt(x, 10) || 0)
const maxLen = Math.max(aParts.length, bParts.length)
for (let i = 0; i < maxLen; i += 1) {
const diff = (aParts[i] || 0) - (bParts[i] || 0)
if (diff !== 0) return diff
}
return 0
}
export class SkillInstallerService {
private listSkillSourceCandidates(skillName: string): string[] {
const source = MANAGED_SKILLS[skillName]
if (!source) return []
const roots = [
join(process.resourcesPath, 'builtin-skills'),
app.getAppPath(),
join(process.resourcesPath, 'app.asar'),
join(process.resourcesPath, 'app.asar.unpacked'),
process.resourcesPath,
process.cwd()
]
const seen = new Set<string>()
const candidates: string[] = []
for (const root of roots) {
for (const relativePath of source.relativePaths) {
const candidate = join(root, relativePath)
const normalized = candidate.toLowerCase()
if (seen.has(normalized)) continue
seen.add(normalized)
candidates.push(candidate)
}
}
return candidates
}
private getSkillSourcePath(skillName: string): string | null {
for (const candidate of this.listSkillSourceCandidates(skillName)) {
if (existsSync(join(candidate, 'SKILL.md'))) return candidate
}
return null
}
private readSkillMeta(skillDir: string): SkillMeta | null {
try {
const metaPath = join(skillDir, '.skill-meta.json')
@@ -78,165 +72,16 @@ export class SkillInstallerService {
}
}
private getSkillSourcePath(skillName: string): string | null {
const source = MANAGED_SKILLS[skillName]
if (!source) return null
return join(app.getAppPath(), source.relativePath)
}
private getBundledVersion(skillName: string): string {
const sourcePath = this.getSkillSourcePath(skillName)
if (!sourcePath) return '0.0.0'
return this.readSkillMeta(sourcePath)?.version || '0.0.0'
}
private collectDiscoveredSkillDirs(): Array<{ agentKind: SupportedAgentKind; agentLabel: string; skillsDir: string; source: 'discovered' }> {
const home = getHomeDir()
if (!home || !existsSync(home)) return []
const results: Array<{ agentKind: SupportedAgentKind; agentLabel: string; skillsDir: string; source: 'discovered' }> = []
const seen = new Set<string>()
const projectRoot = app.getAppPath().toLowerCase()
const addIfMatch = (candidate: string) => {
const normalized = candidate.toLowerCase()
if (seen.has(normalized)) return
if (!existsSync(candidate)) return
if (!statSync(candidate).isDirectory()) return
if (!normalized.endsWith('\\skills') && !normalized.endsWith('/skills')) return
if (normalized.includes(projectRoot)) return
const parentHint = dirname(candidate).toLowerCase()
if (!/(codex|agent|agents|claude|cursor|kiro|trae)/.test(parentHint)) return
seen.add(normalized)
results.push({
agentKind: /codex/.test(parentHint) ? 'codex' : 'agents',
agentLabel: parentHint.includes('cursor')
? '发现的 Cursor Skills'
: parentHint.includes('kiro')
? '发现的 Kiro Skills'
: parentHint.includes('trae')
? '发现的 Trae Skills'
: parentHint.includes('claude')
? '发现的 Claude/Agent Skills'
: '发现的 Skills 目录',
skillsDir: candidate,
source: 'discovered'
})
}
try {
for (const entry of readdirSync(home, { withFileTypes: true })) {
if (!entry.isDirectory() || !entry.name.startsWith('.')) continue
const levelOne = join(home, entry.name)
addIfMatch(join(levelOne, 'skills'))
}
} catch {
// ignore scan errors
}
return results
}
detectTargets(skillName: string): SkillInstallTarget[] {
const sourcePath = this.getSkillSourcePath(skillName)
const hasSource = Boolean(sourcePath && existsSync(join(sourcePath, 'SKILL.md')))
const bundledVersion = this.getBundledVersion(skillName)
const mergedTargets = [...getKnownAgentTargets(), ...this.collectDiscoveredSkillDirs()]
.filter((target, index, arr) => arr.findIndex((item) => item.skillsDir.toLowerCase() === target.skillsDir.toLowerCase()) === index)
return mergedTargets.map(({ agentKind, agentLabel, skillsDir, source }) => {
const installPath = join(skillsDir, skillName)
const installed = existsSync(join(installPath, 'SKILL.md'))
const installedVersion = installed ? (this.readSkillMeta(installPath)?.version || undefined) : undefined
return {
agentKind,
agentLabel,
source,
skillsDir,
supported: hasSource && Boolean(getHomeDir()),
installed,
bundledVersion,
installedVersion,
updateAvailable: Boolean(installedVersion && compareVersions(installedVersion, bundledVersion) < 0),
installPath,
error: hasSource ? undefined : `Skill source not found for ${skillName}`
}
})
}
installSkill(skillName: string, selectedSkillsDirs?: string[]): { success: boolean; results: SkillInstallTarget[]; error?: string } {
const sourcePath = this.getSkillSourcePath(skillName)
if (!sourcePath || !existsSync(join(sourcePath, 'SKILL.md'))) {
return {
success: false,
error: `Skill source not found for ${skillName}`,
results: this.detectTargets(skillName)
}
}
const selectedSet = selectedSkillsDirs?.length
? new Set(selectedSkillsDirs.map((item) => item.toLowerCase()))
: null
const results = this.detectTargets(skillName).map((target) => {
if (selectedSet && !selectedSet.has(target.skillsDir.toLowerCase())) {
return target
}
if (!target.supported || !target.installPath) {
return {
...target,
installed: false,
error: target.error || 'Target is not supported on this device'
}
}
try {
mkdirSync(dirname(target.installPath), { recursive: true })
if (existsSync(target.installPath)) {
rmSync(target.installPath, { recursive: true, force: true })
}
mkdirSync(target.skillsDir, { recursive: true })
cpSync(sourcePath, target.installPath, { recursive: true, force: true })
const installedMeta = this.readSkillMeta(target.installPath)
return {
...target,
installed: true,
installedVersion: installedMeta?.version || target.bundledVersion,
updateAvailable: false,
error: undefined
}
} catch (error) {
return {
...target,
installed: false,
error: String(error)
}
}
})
return {
success: results.some((item) => item.installed),
results,
error: results.every((item) => !item.installed)
? results.map((item) => `${item.agentLabel}: ${item.error || 'install failed'}`).join(' | ')
: undefined
}
}
exportSkillZip(skillName: string): { success: boolean; outputPath?: string; fileName?: string; version?: string; error?: string } {
const sourcePath = this.getSkillSourcePath(skillName)
if (!sourcePath || !existsSync(join(sourcePath, 'SKILL.md'))) {
return { success: false, error: `Skill source not found for ${skillName}` }
if (!sourcePath) {
const tried = this.listSkillSourceCandidates(skillName)
return { success: false, error: `Skill source not found for ${skillName}. Tried: ${tried.join(' | ')}` }
}
try {
const downloadsDir = app.getPath('downloads')
const version = this.getBundledVersion(skillName)
const version = this.readSkillMeta(sourcePath)?.version || '0.0.0'
const fileName = `${skillName}-v${version}.zip`
const outputPath = join(downloadsDir, fileName)
const zip = new AdmZip()
+4
View File
@@ -170,6 +170,10 @@
{
"from": ".tmp/release-announcement.json",
"to": "release-announcement.json"
},
{
"from": "sikll/ct-mcp-copilot",
"to": "builtin-skills/ct-mcp-copilot"
}
],
"extraFiles": [
+4 -258
View File
@@ -6,7 +6,6 @@ import {
Card,
CardContent,
CardHeader,
Checkbox,
Chip,
Container,
Snackbar,
@@ -15,9 +14,8 @@ import {
TextField,
Typography,
} from '@mui/material'
import { Check, Copy, Download, RefreshCw, Save, Sparkles } from 'lucide-react'
import { Check, Copy, Download, Save } from 'lucide-react'
import * as configService from '../services/config'
import type { SkillInstallTarget } from '../types/electron'
type ToastState = {
text: string
@@ -94,58 +92,6 @@ const secondaryButtonSx = {
},
}
const getChipSx = (tone: 'primary' | 'success' | 'warning' | 'neutral' = 'neutral') => {
if (tone === 'primary') {
return {
borderRadius: '999px',
border: '1px solid var(--primary)',
color: 'var(--primary)',
backgroundColor: 'var(--primary-light)',
fontWeight: 700,
'& .MuiChip-label': {
px: 1.1,
},
}
}
if (tone === 'success') {
return {
borderRadius: '999px',
border: '1px solid rgba(76, 175, 80, 0.28)',
color: '#4CAF50',
backgroundColor: 'rgba(76, 175, 80, 0.1)',
fontWeight: 700,
'& .MuiChip-label': {
px: 1.1,
},
}
}
if (tone === 'warning') {
return {
borderRadius: '999px',
border: '1px solid rgba(245, 158, 11, 0.28)',
color: '#f59e0b',
backgroundColor: 'rgba(245, 158, 11, 0.12)',
fontWeight: 700,
'& .MuiChip-label': {
px: 1.1,
},
}
}
return {
borderRadius: '999px',
border: '1px solid var(--border-color)',
color: 'var(--text-secondary)',
backgroundColor: 'var(--bg-secondary)',
fontWeight: 700,
'& .MuiChip-label': {
px: 1.1,
},
}
}
function McpPage() {
const managedSkillName = 'ct-mcp-copilot'
const [activeSection, setActiveSection] = useState<McpSection>('mcp')
@@ -153,10 +99,6 @@ function McpPage() {
const [mcpExposeMediaPaths, setMcpExposeMediaPaths] = useState(true)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [skillTargets, setSkillTargets] = useState<SkillInstallTarget[]>([])
const [selectedSkillDirs, setSelectedSkillDirs] = useState<string[]>([])
const [detectingSkills, setDetectingSkills] = useState(false)
const [installingSkill, setInstallingSkill] = useState(false)
const [exportingSkillZip, setExportingSkillZip] = useState(false)
const [toast, setToast] = useState<ToastState | null>(null)
const [launchConfig, setLaunchConfig] = useState<McpLaunchConfig>({
@@ -187,14 +129,6 @@ function McpPage() {
console.error('获取 MCP 启动配置失败:', innerError)
}
}
try {
const targets = await window.electronAPI.skillInstaller.detectTargets(managedSkillName)
setSkillTargets(targets)
setSelectedSkillDirs(targets.filter((item) => item.supported && (!item.installed || item.updateAvailable)).map((item) => item.skillsDir))
} catch (innerError) {
console.error('检测 Skills 安装目标失败:', innerError)
}
} catch (e) {
console.error('加载 MCP 配置失败:', e)
setToast({ text: '加载 MCP 配置失败', success: false })
@@ -246,44 +180,6 @@ function McpPage() {
}
}
const detectSkillTargets = async () => {
setDetectingSkills(true)
try {
const targets = await window.electronAPI.skillInstaller.detectTargets(managedSkillName)
setSkillTargets(targets)
setSelectedSkillDirs(targets.filter((item) => item.supported && (!item.installed || item.updateAvailable)).map((item) => item.skillsDir))
setToast({ text: '已刷新 Skills 安装目标', success: true })
} catch (e) {
console.error('检测 Skills 安装目标失败:', e)
setToast({ text: '检测 Skills 安装目标失败', success: false })
} finally {
setDetectingSkills(false)
}
}
const installManagedSkill = async () => {
if (selectedSkillDirs.length === 0) {
setToast({ text: '请先勾选要安装的 Agent 目标', success: false })
return
}
setInstallingSkill(true)
try {
const result = await window.electronAPI.skillInstaller.installSkill(managedSkillName, selectedSkillDirs)
setSkillTargets(result.results)
setSelectedSkillDirs(result.results.filter((item) => item.supported && (!item.installed || item.updateAvailable)).map((item) => item.skillsDir))
if (result.success) {
setToast({ text: `${managedSkillName} 已安装到选中的 Agent`, success: true })
} else {
setToast({ text: result.error || 'Skill 安装失败', success: false })
}
} catch (e) {
console.error('安装 Skill 失败:', e)
setToast({ text: '安装 Skill 失败', success: false })
} finally {
setInstallingSkill(false)
}
}
const exportManagedSkillZip = async () => {
setExportingSkillZip(true)
try {
@@ -301,16 +197,6 @@ function McpPage() {
}
}
const bundledSkillVersion = skillTargets[0]?.bundledVersion || '1.0.0'
const selectableTargets = skillTargets.filter((item) => item.supported)
const allSelectableChecked = selectableTargets.length > 0 && selectableTargets.every((item) => selectedSkillDirs.includes(item.skillsDir))
const toggleSkillDir = (skillsDir: string, checked: boolean) => {
setSelectedSkillDirs((current) => checked
? Array.from(new Set([...current, skillsDir]))
: current.filter((item) => item !== skillsDir))
}
return (
<Box sx={{ height: '100%', mx: -3, mt: -3, overflowY: 'auto', pb: 3 }}>
<Container maxWidth="lg" sx={{ px: { xs: 2, md: 4 }, py: { xs: 3, md: 4 } }}>
@@ -573,9 +459,9 @@ function McpPage() {
>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.2} justifyContent="space-between" alignItems={{ xs: 'stretch', sm: 'center' }}>
<Box>
<Typography sx={{ fontWeight: 600, color: 'var(--text-primary)' }}> Skill </Typography>
<Typography sx={{ fontWeight: 600, color: 'var(--text-primary)' }}> Skill</Typography>
<Typography sx={{ mt: 0.5, fontSize: 13, color: 'var(--text-secondary)' }}>
`{bundledSkillVersion}`
`ct-mcp-copilot` zip Skills AgentCodexClaudeCursorKiro
</Typography>
</Box>
<Button
@@ -590,148 +476,8 @@ function McpPage() {
</Stack>
</Box>
<Box
sx={{
p: 2,
borderRadius: '18px',
border: '1px solid var(--border-color)',
bgcolor: 'var(--bg-primary)',
}}
>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.2} justifyContent="space-between" alignItems={{ xs: 'stretch', sm: 'center' }}>
<Box>
<Typography sx={{ fontWeight: 600, color: 'var(--text-primary)' }}> Agent</Typography>
<Typography sx={{ mt: 0.5, fontSize: 13, color: 'var(--text-secondary)' }}>
Codex`.agents` skills `ct-mcp-copilot`
</Typography>
</Box>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.2}>
<Button
variant="outlined"
startIcon={<RefreshCw size={16} />}
onClick={detectSkillTargets}
disabled={detectingSkills || installingSkill}
sx={secondaryButtonSx}
>
{detectingSkills ? '检测中...' : '检测目标'}
</Button>
<Button
variant="contained"
startIcon={<Sparkles size={16} />}
onClick={installManagedSkill}
disabled={installingSkill}
sx={{
minWidth: 140,
borderRadius: '999px',
textTransform: 'none',
fontWeight: 700,
background: 'var(--primary-gradient)',
'&:hover': {
background: 'var(--primary-gradient)',
filter: 'brightness(0.98)',
},
}}
>
{installingSkill ? '安装中...' : '安装选中'}
</Button>
</Stack>
</Stack>
<Stack direction="row" spacing={1} sx={{ mt: 1.5 }}>
<Button
variant="outlined"
onClick={() => setSelectedSkillDirs(selectableTargets.map((item) => item.skillsDir))}
disabled={selectableTargets.length === 0}
sx={secondaryButtonSx}
>
</Button>
<Button
variant="outlined"
onClick={() => setSelectedSkillDirs([])}
disabled={selectedSkillDirs.length === 0}
sx={secondaryButtonSx}
>
</Button>
</Stack>
</Box>
<Stack spacing={1.2}>
{skillTargets.map((target) => (
<Box
key={`${target.agentKind}-${target.skillsDir}`}
sx={{
p: 2,
borderRadius: '18px',
border: '1px solid var(--border-color)',
bgcolor: 'var(--bg-primary)',
}}
>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} justifyContent="space-between" alignItems={{ xs: 'flex-start', sm: 'center' }}>
<Box sx={{ minWidth: 0 }}>
<Stack direction="row" spacing={1} alignItems="center" useFlexGap flexWrap="wrap">
<Checkbox
checked={selectedSkillDirs.includes(target.skillsDir)}
indeterminate={false}
disabled={!target.supported}
onChange={(event) => toggleSkillDir(target.skillsDir, event.target.checked)}
sx={{
color: 'var(--text-tertiary)',
'&.Mui-checked': {
color: 'var(--primary)',
},
p: 0.5,
mr: 0.5,
}}
/>
<Typography sx={{ fontWeight: 600, color: 'var(--text-primary)' }}>
{target.agentLabel}
</Typography>
<Chip
label={target.installed ? '已安装' : target.supported ? '可安装' : '不支持'}
size="small"
variant="outlined"
sx={target.installed ? getChipSx('success') : target.supported ? getChipSx('primary') : getChipSx('neutral')}
/>
{target.updateAvailable && (
<Chip
label="可更新"
size="small"
variant="outlined"
sx={getChipSx('warning')}
/>
)}
<Chip
label={target.source === 'known' ? '内置规则' : '扫描发现'}
size="small"
variant="outlined"
sx={getChipSx('neutral')}
/>
</Stack>
<Typography sx={{ mt: 0.75, fontSize: 13, color: 'var(--text-secondary)', wordBreak: 'break-all' }}>
{target.installPath || target.skillsDir}
</Typography>
<Typography sx={{ mt: 0.5, fontSize: 12, color: 'var(--text-secondary)' }}>
{target.installedVersion || '未安装'} / {target.bundledVersion}
</Typography>
{target.error && (
<Typography sx={{ mt: 0.75, fontSize: 12, color: 'var(--danger)' }}>
{target.error}
</Typography>
)}
</Box>
</Stack>
</Box>
))}
{skillTargets.length === 0 && (
<Typography sx={{ fontSize: 13, color: 'var(--text-secondary)' }}>
Skill
</Typography>
)}
</Stack>
<Typography sx={{ fontSize: 13, color: 'var(--text-secondary)' }}>
Skills Agent `ct-mcp-copilot` 使 `zip` Cherry Studio MCP 宿使 `mcpServers` skills
zip Agent skills 使Cherry Studio MCP 宿使 `mcpServers` Skill
</Typography>
</Stack>
</CardContent>
-15
View File
@@ -2,19 +2,6 @@ import type { ChatSession, Message, Contact, ContactInfo } from './models'
import type { SummaryResult } from './ai'
import type { AccountProfile } from './account'
export interface SkillInstallTarget {
agentKind: 'codex' | 'agents'
agentLabel: string
source: 'known' | 'discovered'
skillsDir: string
supported: boolean
installed: boolean
bundledVersion: string
installedVersion?: string
updateAvailable: boolean
installPath?: string
error?: string
}
export interface ImageListItem {
imagePath: string
@@ -81,8 +68,6 @@ export interface ElectronAPI {
delete: (accountId: string, deleteLocalData?: boolean) => Promise<{ success: boolean; error?: string; deleted?: AccountProfile | null; nextActiveAccountId?: string }>
}
skillInstaller: {
detectTargets: (skillName: string) => Promise<SkillInstallTarget[]>
installSkill: (skillName: string, selectedSkillsDirs?: string[]) => Promise<{ success: boolean; results: SkillInstallTarget[]; error?: string }>
exportSkillZip: (skillName: string) => Promise<{ success: boolean; outputPath?: string; fileName?: string; version?: string; error?: string }>
}
db: {