feat: add bundled skill installer support

This commit is contained in:
ILoveBingLu
2026-04-07 16:02:44 +08:00
parent 69d875f788
commit e436bf2d36
12 changed files with 569 additions and 4 deletions

View File

@@ -362,6 +362,44 @@ macOS:
macOS 打包态请直接指向 `.app` 内部的 `ciphertalk-mcp`,不要把 `CipherTalk.app` 本体当作 `command`
### AI Copilot Skill
项目内置了 `ct-mcp-copilot` skill用于让支持 Skills 的 Agent 更智能地使用 CipherTalk MCP
- 模糊联系人 / 会话查找
- 线索补挖和候选比较
- 导出前补问和请求校验
在应用内的 MCP 页面可以一键安装到本机支持的 Agent
- Codex`~/.codex/skills`
- `.agents``~/.agents/skills`
- 以及主目录下发现的其他 `skills` 目录(如路径特征明显匹配 Agent
Skill 使用独立版本号,不跟应用版本绑定。页面会显示:
- 当前内置 skill 版本
- 本机已安装版本
- 是否可更新(仅对比本地已安装版本是否落后)
还支持直接导出本地 skill 压缩包:
- 文件名格式:`ct-mcp-copilot-v<skillVersion>.zip`
- 默认导出到系统 Downloads 目录
- 可用于手动导入到支持 skills 的 Agent
安装后可直接这样使用:
- `使用 ct-mcp-copilot 帮我查这个人`
- `使用 ct-mcp-copilot 帮我补全导出问题`
说明:
- 安装器使用“复制安装”,不会创建软链接
- 当前只管理内置 skill `ct-mcp-copilot`
- 当前只检查本地已安装版本是否落后,不检查远程最新版本
- Cherry Studio 等 MCP 宿主继续使用 `mcpServers` 配置,不属于 skills 目录安装模型
### 参数示例
```json

View File

@@ -32,6 +32,7 @@ import { httpApiService } from './services/httpApiService'
import { getBestCachePath, getRuntimePlatformInfo } from './services/platformService'
import { getMcpLaunchConfig as getMcpLaunchConfigForUi, getMcpProxyConfig } from './services/mcp/runtime'
import { mcpProxyService } from './services/mcp/proxyService'
import { skillInstallerService } from './services/skillInstallerService'
type AppWithQuitFlag = typeof app & {
isQuitting?: boolean
@@ -1337,6 +1338,18 @@ 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) => {
return skillInstallerService.installSkill(skillName)
})
ipcMain.handle('skillInstaller:exportSkillZip', async (_, skillName: string) => {
return skillInstallerService.exportSkillZip(skillName)
})
// HTTP API 管理
ipcMain.handle('httpApi:getStatus', async () => {
return { success: true, status: httpApiService.getUiStatus() }

View File

@@ -1,5 +1,6 @@
import { contextBridge, ipcRenderer } from 'electron'
import type { AccountProfile } from '../src/types/account'
import type { SkillInstallTarget } from './services/skillInstallerService'
function getMcpLaunchConfigSafe(): Promise<{
command: string
@@ -45,6 +46,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('accounts:delete', accountId, deleteLocalData) as Promise<{ success: boolean; error?: string; deleted?: AccountProfile | null; nextActiveAccountId?: string }>
},
skillInstaller: {
detectTargets: (skillName: string) => ipcRenderer.invoke('skillInstaller:detectTargets', skillName) as Promise<SkillInstallTarget[]>,
installSkill: (skillName: string) =>
ipcRenderer.invoke('skillInstaller:installSkill', skillName) 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 }>
},
// 数据库操作
db: {
open: (dbPath: string, key?: string) => ipcRenderer.invoke('db:open', dbPath, key),

View File

@@ -317,7 +317,8 @@ export class ConfigService {
const cachePath = String(profile.cachePath ?? fallback?.cachePath ?? '').trim()
const imageXorKey = String(profile.imageXorKey ?? fallback?.imageXorKey ?? '').trim()
const imageAesKey = String(profile.imageAesKey ?? fallback?.imageAesKey ?? '').trim()
const displayName = String(profile.displayName ?? fallback?.displayName ?? wxid || '未命名账号').trim() || wxid || '未命名账号'
const rawDisplayName = profile.displayName ?? fallback?.displayName ?? wxid ?? ''
const displayName = String(rawDisplayName).trim() || wxid || '未命名账号'
return {
wxid,

View File

@@ -0,0 +1,236 @@
import { app } from 'electron'
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from 'fs'
import { homedir } from 'os'
import { dirname, 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
}
type SkillMeta = {
name: string
version: string
description?: string
}
const MANAGED_SKILLS: Record<string, SkillSource> = {
'ct-mcp-copilot': {
name: 'ct-mcp-copilot',
relativePath: 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: 'agents', agentLabel: '.agents', skillsDir: join(home, '.agents', '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 readSkillMeta(skillDir: string): SkillMeta | null {
try {
const metaPath = join(skillDir, '.skill-meta.json')
if (!existsSync(metaPath)) return null
return JSON.parse(readFileSync(metaPath, 'utf8')) as SkillMeta
} catch {
return null
}
}
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)/.test(parentHint)) return
seen.add(normalized)
results.push({
agentKind: /codex/.test(parentHint) ? 'codex' : 'agents',
agentLabel: 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'))
for (const nested of readdirSync(levelOne, { withFileTypes: true })) {
if (!nested.isDirectory()) continue
addIfMatch(join(levelOne, nested.name, '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): { 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 results = this.detectTargets(skillName).map((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}` }
}
try {
const downloadsDir = app.getPath('downloads')
const version = this.getBundledVersion(skillName)
const fileName = `${skillName}-v${version}.zip`
const outputPath = join(downloadsDir, fileName)
const zip = new AdmZip()
zip.addLocalFolder(sourcePath, skillName)
zip.writeZip(outputPath)
return { success: true, outputPath, fileName, version }
} catch (error) {
return { success: false, error: String(error) }
}
}
}
export const skillInstallerService = new SkillInstallerService()

View File

@@ -995,7 +995,7 @@ export class WxKeyServiceMac {
}
const current = chunk.subarray(0, bytesRead)
const data = trailing ? Buffer.concat([trailing, current]) : current
const data: Buffer = trailing ? Buffer.concat([trailing, current]) : current
const key = this.searchAsciiKey(data, ciphertext) || this.searchUtf16Key(data, ciphertext) || this.searchAny16Key(data, ciphertext)
if (key) return key
trailing = data.subarray(Math.max(0, data.length - OVERLAP))

9
electron/types/adm-zip.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
declare module 'adm-zip' {
class AdmZip {
constructor(path?: string)
addLocalFolder(localPath: string, zipPath?: string): void
writeZip(targetPath: string): void
}
export = AdmZip
}

View File

@@ -180,6 +180,7 @@
"files": [
"dist/**/*",
"dist-electron/**/*",
"sikll/**/*",
"!node_modules/**/*.{txt,md,js.map,ts,html}",
"!node_modules/**/test/**/*",
"!node_modules/**/docs/**/*",

View File

@@ -0,0 +1,5 @@
{
"name": "ct-mcp-copilot",
"version": "1.0.0",
"description": "Use CipherTalk MCP as an AI copilot for fuzzy contact lookup, session resolution, search, context retrieval, and export follow-up questions."
}

View File

@@ -6,6 +6,7 @@ import {
Card,
CardContent,
CardHeader,
Chip,
Container,
Snackbar,
Stack,
@@ -13,8 +14,9 @@ import {
TextField,
Typography,
} from '@mui/material'
import { Check, Copy, Save } from 'lucide-react'
import { Check, Copy, Download, RefreshCw, Save, Sparkles } from 'lucide-react'
import * as configService from '../services/config'
import type { SkillInstallTarget } from '../types/electron'
type ToastState = {
text: string
@@ -90,10 +92,15 @@ const secondaryButtonSx = {
}
function McpPage() {
const managedSkillName = 'ct-mcp-copilot'
const [mcpEnabled, setMcpEnabled] = useState(false)
const [mcpExposeMediaPaths, setMcpExposeMediaPaths] = useState(true)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [skillTargets, setSkillTargets] = useState<SkillInstallTarget[]>([])
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>({
command: 'npm',
@@ -123,6 +130,13 @@ function McpPage() {
console.error('获取 MCP 启动配置失败:', innerError)
}
}
try {
const targets = await window.electronAPI.skillInstaller.detectTargets(managedSkillName)
setSkillTargets(targets)
} catch (innerError) {
console.error('检测 Skills 安装目标失败:', innerError)
}
} catch (e) {
console.error('加载 MCP 配置失败:', e)
setToast({ text: '加载 MCP 配置失败', success: false })
@@ -174,6 +188,57 @@ function McpPage() {
}
}
const detectSkillTargets = async () => {
setDetectingSkills(true)
try {
const targets = await window.electronAPI.skillInstaller.detectTargets(managedSkillName)
setSkillTargets(targets)
setToast({ text: '已刷新 Skills 安装目标', success: true })
} catch (e) {
console.error('检测 Skills 安装目标失败:', e)
setToast({ text: '检测 Skills 安装目标失败', success: false })
} finally {
setDetectingSkills(false)
}
}
const installManagedSkill = async () => {
setInstallingSkill(true)
try {
const result = await window.electronAPI.skillInstaller.installSkill(managedSkillName)
setSkillTargets(result.results)
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 {
const result = await window.electronAPI.skillInstaller.exportSkillZip(managedSkillName)
if (result.success) {
setToast({ text: `Skill 压缩包已导出到 ${result.outputPath}`, success: true })
} else {
setToast({ text: result.error || '导出 Skill 压缩包失败', success: false })
}
} catch (e) {
console.error('导出 Skill 压缩包失败:', e)
setToast({ text: '导出 Skill 压缩包失败', success: false })
} finally {
setExportingSkillZip(false)
}
}
const bundledSkillVersion = skillTargets[0]?.bundledVersion || '1.0.0'
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 } }}>
@@ -362,6 +427,175 @@ function McpPage() {
</Stack>
</CardContent>
</Card>
<Card
sx={{
borderRadius: '26px',
border: '1px solid var(--border-color)',
bgcolor: 'var(--bg-secondary)',
boxShadow: 'none',
}}
>
<CardHeader
title="AI Copilot Skill"
titleTypographyProps={{ fontWeight: 700, fontSize: 18, color: 'var(--text-primary)' }}
sx={{ px: { xs: 2, md: 3 }, pb: 0.8 }}
/>
<CardContent sx={{ px: { xs: 2, md: 3 }, pt: 0.6 }}>
<Stack spacing={2.2}>
<Alert
severity="info"
variant="outlined"
sx={{
borderRadius: '18px',
bgcolor: 'var(--bg-primary)',
borderColor: 'var(--border-color)',
color: 'var(--text-primary)',
}}
>
Skill `ct-mcp-copilot` Skills Agent 使 CipherTalk MCP
</Alert>
<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)' }}> Skill </Typography>
<Typography sx={{ mt: 0.5, fontSize: 13, color: 'var(--text-secondary)' }}>
`{bundledSkillVersion}`
</Typography>
</Box>
<Button
variant="outlined"
startIcon={<Download size={16} />}
onClick={exportManagedSkillZip}
disabled={exportingSkillZip}
sx={secondaryButtonSx}
>
{exportingSkillZip ? '导出中...' : '导出 zip'}
</Button>
</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>
</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">
<Typography sx={{ fontWeight: 600, color: 'var(--text-primary)' }}>
{target.agentLabel}
</Typography>
<Chip
label={target.installed ? '已安装' : target.supported ? '可安装' : '不支持'}
size="small"
color={target.installed ? 'success' : target.supported ? 'primary' : 'default'}
variant="outlined"
/>
{target.updateAvailable && (
<Chip
label="可更新"
size="small"
color="warning"
variant="outlined"
/>
)}
<Chip
label={target.source === 'known' ? '内置规则' : '扫描发现'}
size="small"
variant="outlined"
/>
</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
</Typography>
</Stack>
</CardContent>
</Card>
</Stack>
</Container>

View File

@@ -2,6 +2,20 @@ 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
liveVideoPath?: string
@@ -66,6 +80,11 @@ export interface ElectronAPI {
update: (accountId: string, patch: Partial<Omit<AccountProfile, 'id' | 'createdAt' | 'updatedAt' | 'lastUsedAt'>>) => Promise<AccountProfile | null>
delete: (accountId: string, deleteLocalData?: boolean) => Promise<{ success: boolean; error?: string; deleted?: AccountProfile | null; nextActiveAccountId?: string }>
}
skillInstaller: {
detectTargets: (skillName: string) => Promise<SkillInstallTarget[]>
installSkill: (skillName: string) => Promise<{ success: boolean; results: SkillInstallTarget[]; error?: string }>
exportSkillZip: (skillName: string) => Promise<{ success: boolean; outputPath?: string; fileName?: string; version?: string; error?: string }>
}
db: {
open: (dbPath: string, key?: string) => Promise<boolean>
query: <T = unknown>(sql: string, params?: unknown[]) => Promise<T[]>

View File

@@ -9,5 +9,5 @@
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts", "electron/**/*.ts"]
"include": ["vite.config.ts", "electron/**/*.ts", "src/types/account.ts"]
}