mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-27 12:51:26 +08:00
🐛 fix: resolve tool approval UI and shared workspace plugin inconsistency (#11043)
* fix(ToolPermissionRequestCard): simplify button rendering by removing suggestion handling * ✨ feat: add CachedPluginsDataSchema for plugin cache file - Add Zod schema for .claude/plugins.json cache file format - Schema includes version, lastUpdated timestamp, and plugins array - Reuses existing InstalledPluginSchema for type safety - Cache will store metadata for all installed plugins * ✨ feat: add cache management methods to PluginService - Add readCacheFile() to read .claude/plugins.json - Add writeCacheFile() for atomic cache writes (temp + rename) - Add rebuildCache() to scan filesystem and rebuild cache - Add listInstalledFromCache() to load plugins from cache with fallback - Add updateCache() helper for transactional cache updates - All methods handle missing/corrupt cache gracefully - Cache auto-regenerates from filesystem if needed * ✨ feat: integrate cache loading in AgentService.getAgent() - Add installed_plugins field to GetAgentResponseSchema - Load plugins from cache via PluginService.listInstalledFromCache() - Gracefully handle errors by returning empty array - Use loggerService for error logging * 🐛 fix: break circular dependency causing infinite loop in cache methods - Change cache method signatures from agentId to workdir parameter - Update listInstalledFromCache(workdir) to accept workdir directly - Update rebuildCache(workdir) to accept workdir directly - Update updateCache(workdir, updater) to accept workdir directly - AgentService.getAgent() now passes accessible_paths[0] to cache methods - Removes AgentService.getAgent() calls from PluginService methods - Fixes infinite recursion bug where methods called each other endlessly Breaking the circular dependency: BEFORE: AgentService.getAgent() → PluginService.listInstalledFromCache(id) → AgentService.getAgent(id) [INFINITE LOOP] AFTER: AgentService.getAgent() → PluginService.listInstalledFromCache(workdir) [NO MORE RECURSION] * 🐛 fix: update listInstalled() to use agent.installed_plugins - Change from agent.configuration.installed_plugins (old DB location) - To agent.installed_plugins (new top-level field from cache) - Simplify validation logic to use existing plugin structure - Fixes UI not showing installed plugins correctly This was causing the UI to show empty plugin lists even though plugins were correctly loaded in the cache by AgentService.getAgent(). * ♻️ refactor: remove unused updateCache helper * ♻️ refactor: centralize plugin directory helpers * feat: Implement Plugin Management System - Added PluginCacheStore for managing plugin metadata and caching. - Introduced PluginInstaller for handling installation and uninstallation of plugins. - Created PluginService to manage plugin lifecycle, including installation, uninstallation, and listing of available plugins. - Enhanced AgentService to integrate with PluginService for loading installed plugins. - Implemented validation and sanitization for plugin file names and paths to prevent security issues. - Added support for skills as a new plugin type, including installation and management. - Introduced caching mechanism for available plugins to improve performance. * ♻️ refactor: simplify PluginInstaller and PluginService by removing agent dependency and updating plugin handling
This commit is contained in:
parent
f8a599322f
commit
d792bf7fe0
@ -27,6 +27,7 @@ import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPref
|
||||
import fontList from 'font-list'
|
||||
|
||||
import { agentMessageRepository } from './services/agents/database'
|
||||
import { PluginService } from './services/agents/plugins/PluginService'
|
||||
import { apiServerService } from './services/ApiServerService'
|
||||
import appService from './services/AppService'
|
||||
import AppUpdater from './services/AppUpdater'
|
||||
@ -47,7 +48,6 @@ import * as NutstoreService from './services/NutstoreService'
|
||||
import ObsidianVaultService from './services/ObsidianVaultService'
|
||||
import { ocrService } from './services/ocr/OcrService'
|
||||
import OvmsManager from './services/OvmsManager'
|
||||
import { PluginService } from './services/PluginService'
|
||||
import { proxyManager } from './services/ProxyManager'
|
||||
import { pythonService } from './services/PythonService'
|
||||
import { FileServiceManager } from './services/remotefile/FileServiceManager'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
426
src/main/services/agents/plugins/PluginCacheStore.ts
Normal file
426
src/main/services/agents/plugins/PluginCacheStore.ts
Normal file
@ -0,0 +1,426 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { findAllSkillDirectories, parsePluginMetadata, parseSkillMetadata } from '@main/utils/markdownParser'
|
||||
import type { CachedPluginsData, InstalledPlugin, PluginError, PluginMetadata, PluginType } from '@types'
|
||||
import { CachedPluginsDataSchema } from '@types'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
|
||||
const logger = loggerService.withContext('PluginCacheStore')
|
||||
|
||||
interface PluginCacheStoreDeps {
|
||||
allowedExtensions: string[]
|
||||
getPluginDirectoryName: (type: PluginType) => 'agents' | 'commands' | 'skills'
|
||||
getClaudeBasePath: (workdir: string) => string
|
||||
getClaudePluginDirectory: (workdir: string, type: PluginType) => string
|
||||
getPluginsBasePath: () => string
|
||||
}
|
||||
|
||||
export class PluginCacheStore {
|
||||
constructor(private readonly deps: PluginCacheStoreDeps) {}
|
||||
|
||||
async listAvailableFilePlugins(type: 'agent' | 'command'): Promise<PluginMetadata[]> {
|
||||
const basePath = this.deps.getPluginsBasePath()
|
||||
const directory = path.join(basePath, this.deps.getPluginDirectoryName(type))
|
||||
|
||||
try {
|
||||
await fs.promises.access(directory, fs.constants.R_OK)
|
||||
} catch (error) {
|
||||
logger.warn(`Plugin directory not accessible: ${directory}`, {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return []
|
||||
}
|
||||
|
||||
const plugins: PluginMetadata[] = []
|
||||
const categories = await fs.promises.readdir(directory, { withFileTypes: true })
|
||||
|
||||
for (const categoryEntry of categories) {
|
||||
if (!categoryEntry.isDirectory()) {
|
||||
continue
|
||||
}
|
||||
|
||||
const category = categoryEntry.name
|
||||
const categoryPath = path.join(directory, category)
|
||||
const files = await fs.promises.readdir(categoryPath, { withFileTypes: true })
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.isFile()) {
|
||||
continue
|
||||
}
|
||||
|
||||
const ext = path.extname(file.name).toLowerCase()
|
||||
if (!this.deps.allowedExtensions.includes(ext)) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const filePath = path.join(categoryPath, file.name)
|
||||
const sourcePath = path.join(this.deps.getPluginDirectoryName(type), category, file.name)
|
||||
const metadata = await parsePluginMetadata(filePath, sourcePath, category, type)
|
||||
plugins.push(metadata)
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to parse plugin: ${file.name}`, {
|
||||
category,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return plugins
|
||||
}
|
||||
|
||||
async listAvailableSkills(): Promise<PluginMetadata[]> {
|
||||
const basePath = this.deps.getPluginsBasePath()
|
||||
const skillsPath = path.join(basePath, this.deps.getPluginDirectoryName('skill'))
|
||||
const skills: PluginMetadata[] = []
|
||||
|
||||
try {
|
||||
await fs.promises.access(skillsPath)
|
||||
} catch {
|
||||
logger.warn('Skills directory not found', { skillsPath })
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const skillDirectories = await findAllSkillDirectories(skillsPath, basePath)
|
||||
logger.info(`Found ${skillDirectories.length} skill directories`, { skillsPath })
|
||||
|
||||
for (const { folderPath, sourcePath } of skillDirectories) {
|
||||
try {
|
||||
const metadata = await parseSkillMetadata(folderPath, sourcePath, 'skills')
|
||||
skills.push(metadata)
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to parse skill folder: ${sourcePath}`, {
|
||||
folderPath,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to scan skill directory', {
|
||||
skillsPath,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
|
||||
return skills
|
||||
}
|
||||
|
||||
async readSourceContent(sourcePath: string): Promise<string> {
|
||||
const absolutePath = this.resolveSourcePath(sourcePath)
|
||||
|
||||
try {
|
||||
await fs.promises.access(absolutePath, fs.constants.R_OK)
|
||||
} catch {
|
||||
throw {
|
||||
type: 'FILE_NOT_FOUND',
|
||||
path: sourcePath
|
||||
} as PluginError
|
||||
}
|
||||
|
||||
try {
|
||||
return await fs.promises.readFile(absolutePath, 'utf-8')
|
||||
} catch (error) {
|
||||
throw {
|
||||
type: 'READ_FAILED',
|
||||
path: sourcePath,
|
||||
reason: error instanceof Error ? error.message : String(error)
|
||||
} as PluginError
|
||||
}
|
||||
}
|
||||
|
||||
resolveSourcePath(sourcePath: string): string {
|
||||
const normalized = path.normalize(sourcePath)
|
||||
|
||||
if (normalized.includes('..')) {
|
||||
throw {
|
||||
type: 'PATH_TRAVERSAL',
|
||||
message: 'Path traversal detected',
|
||||
path: sourcePath
|
||||
} as PluginError
|
||||
}
|
||||
|
||||
const basePath = this.deps.getPluginsBasePath()
|
||||
const absolutePath = path.join(basePath, normalized)
|
||||
const resolvedPath = path.resolve(absolutePath)
|
||||
|
||||
if (!resolvedPath.startsWith(path.resolve(basePath))) {
|
||||
throw {
|
||||
type: 'PATH_TRAVERSAL',
|
||||
message: 'Path outside plugins directory',
|
||||
path: sourcePath
|
||||
} as PluginError
|
||||
}
|
||||
|
||||
return resolvedPath
|
||||
}
|
||||
|
||||
async ensureSkillSourceDirectory(sourceAbsolutePath: string, sourcePath: string): Promise<void> {
|
||||
let stats: fs.Stats
|
||||
try {
|
||||
stats = await fs.promises.stat(sourceAbsolutePath)
|
||||
} catch {
|
||||
throw {
|
||||
type: 'FILE_NOT_FOUND',
|
||||
path: sourceAbsolutePath
|
||||
} as PluginError
|
||||
}
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
throw {
|
||||
type: 'INVALID_METADATA',
|
||||
reason: 'Skill source is not a directory',
|
||||
path: sourcePath
|
||||
} as PluginError
|
||||
}
|
||||
}
|
||||
|
||||
async validatePluginFile(filePath: string, maxFileSize: number): Promise<void> {
|
||||
let stats: fs.Stats
|
||||
try {
|
||||
stats = await fs.promises.stat(filePath)
|
||||
} catch {
|
||||
throw {
|
||||
type: 'FILE_NOT_FOUND',
|
||||
path: filePath
|
||||
} as PluginError
|
||||
}
|
||||
|
||||
if (stats.size > maxFileSize) {
|
||||
throw {
|
||||
type: 'FILE_TOO_LARGE',
|
||||
size: stats.size,
|
||||
max: maxFileSize
|
||||
} as PluginError
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
if (!this.deps.allowedExtensions.includes(ext)) {
|
||||
throw {
|
||||
type: 'INVALID_FILE_TYPE',
|
||||
extension: ext
|
||||
} as PluginError
|
||||
}
|
||||
|
||||
try {
|
||||
const basePath = this.deps.getPluginsBasePath()
|
||||
const relativeSourcePath = path.relative(basePath, filePath)
|
||||
const segments = relativeSourcePath.split(path.sep)
|
||||
const rootDir = segments[0]
|
||||
const agentDir = this.deps.getPluginDirectoryName('agent')
|
||||
const type: 'agent' | 'command' = rootDir === agentDir ? 'agent' : 'command'
|
||||
const category = path.basename(path.dirname(filePath))
|
||||
|
||||
await parsePluginMetadata(filePath, relativeSourcePath, category, type)
|
||||
} catch (error) {
|
||||
throw {
|
||||
type: 'INVALID_METADATA',
|
||||
reason: 'Failed to parse frontmatter',
|
||||
path: filePath
|
||||
} as PluginError
|
||||
}
|
||||
}
|
||||
|
||||
async listInstalled(workdir: string): Promise<InstalledPlugin[]> {
|
||||
const claudePath = this.deps.getClaudeBasePath(workdir)
|
||||
const cacheData = await this.readCacheFile(claudePath)
|
||||
|
||||
if (cacheData) {
|
||||
logger.debug(`Loaded ${cacheData.plugins.length} plugins from cache`, { workdir })
|
||||
return cacheData.plugins
|
||||
}
|
||||
|
||||
logger.info('Cache read failed, rebuilding from filesystem', { workdir })
|
||||
return await this.rebuild(workdir)
|
||||
}
|
||||
|
||||
async upsert(workdir: string, plugin: InstalledPlugin): Promise<void> {
|
||||
const claudePath = this.deps.getClaudeBasePath(workdir)
|
||||
let cacheData = await this.readCacheFile(claudePath)
|
||||
let plugins = cacheData?.plugins
|
||||
|
||||
if (!plugins) {
|
||||
plugins = await this.rebuild(workdir)
|
||||
cacheData = {
|
||||
version: 1,
|
||||
lastUpdated: Date.now(),
|
||||
plugins
|
||||
}
|
||||
}
|
||||
|
||||
const updatedPlugin: InstalledPlugin = {
|
||||
...plugin,
|
||||
metadata: {
|
||||
...plugin.metadata,
|
||||
installedAt: plugin.metadata.installedAt ?? Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
const index = plugins.findIndex((p) => p.filename === updatedPlugin.filename && p.type === updatedPlugin.type)
|
||||
if (index >= 0) {
|
||||
plugins[index] = updatedPlugin
|
||||
} else {
|
||||
plugins.push(updatedPlugin)
|
||||
}
|
||||
|
||||
const data: CachedPluginsData = {
|
||||
version: cacheData?.version ?? 1,
|
||||
lastUpdated: Date.now(),
|
||||
plugins
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(claudePath, { recursive: true })
|
||||
await this.writeCacheFile(claudePath, data)
|
||||
}
|
||||
|
||||
async remove(workdir: string, filename: string, type: PluginType): Promise<void> {
|
||||
const claudePath = this.deps.getClaudeBasePath(workdir)
|
||||
let cacheData = await this.readCacheFile(claudePath)
|
||||
let plugins = cacheData?.plugins
|
||||
|
||||
if (!plugins) {
|
||||
plugins = await this.rebuild(workdir)
|
||||
cacheData = {
|
||||
version: 1,
|
||||
lastUpdated: Date.now(),
|
||||
plugins
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = plugins.filter((p) => !(p.filename === filename && p.type === type))
|
||||
|
||||
const data: CachedPluginsData = {
|
||||
version: cacheData?.version ?? 1,
|
||||
lastUpdated: Date.now(),
|
||||
plugins: filtered
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(claudePath, { recursive: true })
|
||||
await this.writeCacheFile(claudePath, data)
|
||||
}
|
||||
|
||||
async rebuild(workdir: string): Promise<InstalledPlugin[]> {
|
||||
logger.info('Rebuilding plugin cache from filesystem', { workdir })
|
||||
|
||||
const claudePath = this.deps.getClaudeBasePath(workdir)
|
||||
|
||||
try {
|
||||
await fs.promises.access(claudePath, fs.constants.R_OK)
|
||||
} catch {
|
||||
logger.warn('.claude directory not found, returning empty plugin list', { claudePath })
|
||||
return []
|
||||
}
|
||||
|
||||
const plugins: InstalledPlugin[] = []
|
||||
|
||||
await Promise.all([
|
||||
this.collectFilePlugins(workdir, 'agent', plugins),
|
||||
this.collectFilePlugins(workdir, 'command', plugins),
|
||||
this.collectSkillPlugins(workdir, plugins)
|
||||
])
|
||||
|
||||
try {
|
||||
const cacheData: CachedPluginsData = {
|
||||
version: 1,
|
||||
lastUpdated: Date.now(),
|
||||
plugins
|
||||
}
|
||||
await this.writeCacheFile(claudePath, cacheData)
|
||||
logger.info(`Rebuilt cache with ${plugins.length} plugins`, { workdir })
|
||||
} catch (error) {
|
||||
logger.error('Failed to write cache file after rebuild', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
|
||||
return plugins
|
||||
}
|
||||
|
||||
private async collectFilePlugins(
|
||||
workdir: string,
|
||||
type: Exclude<PluginType, 'skill'>,
|
||||
plugins: InstalledPlugin[]
|
||||
): Promise<void> {
|
||||
const directory = this.deps.getClaudePluginDirectory(workdir, type)
|
||||
|
||||
try {
|
||||
await fs.promises.access(directory, fs.constants.R_OK)
|
||||
} catch {
|
||||
logger.debug(`${type} directory not found or not accessible`, { directory })
|
||||
return
|
||||
}
|
||||
|
||||
const files = await fs.promises.readdir(directory, { withFileTypes: true })
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.isFile()) {
|
||||
continue
|
||||
}
|
||||
|
||||
const ext = path.extname(file.name).toLowerCase()
|
||||
if (!this.deps.allowedExtensions.includes(ext)) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const filePath = path.join(directory, file.name)
|
||||
const sourcePath = path.join(this.deps.getPluginDirectoryName(type), file.name)
|
||||
const metadata = await parsePluginMetadata(filePath, sourcePath, this.deps.getPluginDirectoryName(type), type)
|
||||
plugins.push({ filename: file.name, type, metadata })
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to parse ${type} plugin: ${file.name}`, {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async collectSkillPlugins(workdir: string, plugins: InstalledPlugin[]): Promise<void> {
|
||||
const skillsPath = this.deps.getClaudePluginDirectory(workdir, 'skill')
|
||||
const claudePath = this.deps.getClaudeBasePath(workdir)
|
||||
|
||||
try {
|
||||
await fs.promises.access(skillsPath, fs.constants.R_OK)
|
||||
} catch {
|
||||
logger.debug('Skills directory not found or not accessible', { skillsPath })
|
||||
return
|
||||
}
|
||||
|
||||
const skillDirectories = await findAllSkillDirectories(skillsPath, claudePath)
|
||||
|
||||
for (const { folderPath, sourcePath } of skillDirectories) {
|
||||
try {
|
||||
const metadata = await parseSkillMetadata(folderPath, sourcePath, 'skills')
|
||||
plugins.push({ filename: metadata.filename, type: 'skill', metadata })
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to parse skill plugin: ${sourcePath}`, {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async readCacheFile(claudePath: string): Promise<CachedPluginsData | null> {
|
||||
const cachePath = path.join(claudePath, 'plugins.json')
|
||||
try {
|
||||
const content = await fs.promises.readFile(cachePath, 'utf-8')
|
||||
const data = JSON.parse(content)
|
||||
return CachedPluginsDataSchema.parse(data)
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to read cache file at ${cachePath}`, {
|
||||
error: err instanceof Error ? err.message : String(err)
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private async writeCacheFile(claudePath: string, data: CachedPluginsData): Promise<void> {
|
||||
const cachePath = path.join(claudePath, 'plugins.json')
|
||||
const tempPath = `${cachePath}.tmp`
|
||||
|
||||
const content = JSON.stringify(data, null, 2)
|
||||
await fs.promises.writeFile(tempPath, content, 'utf-8')
|
||||
await fs.promises.rename(tempPath, cachePath)
|
||||
}
|
||||
}
|
||||
149
src/main/services/agents/plugins/PluginInstaller.ts
Normal file
149
src/main/services/agents/plugins/PluginInstaller.ts
Normal file
@ -0,0 +1,149 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { copyDirectoryRecursive, deleteDirectoryRecursive } from '@main/utils/fileOperations'
|
||||
import type { PluginError } from '@types'
|
||||
import * as crypto from 'crypto'
|
||||
import * as fs from 'fs'
|
||||
|
||||
const logger = loggerService.withContext('PluginInstaller')
|
||||
|
||||
export class PluginInstaller {
|
||||
async installFilePlugin(agentId: string, sourceAbsolutePath: string, destPath: string): Promise<void> {
|
||||
const tempPath = `${destPath}.tmp`
|
||||
let fileCopied = false
|
||||
|
||||
try {
|
||||
await fs.promises.copyFile(sourceAbsolutePath, tempPath)
|
||||
fileCopied = true
|
||||
logger.debug('File copied to temp location', { agentId, tempPath })
|
||||
|
||||
await fs.promises.rename(tempPath, destPath)
|
||||
logger.debug('File moved to final location', { agentId, destPath })
|
||||
} catch (error) {
|
||||
if (fileCopied) {
|
||||
await this.safeUnlink(tempPath, 'temp file')
|
||||
}
|
||||
throw this.toPluginError('install', error)
|
||||
}
|
||||
}
|
||||
|
||||
async uninstallFilePlugin(
|
||||
agentId: string,
|
||||
filename: string,
|
||||
type: 'agent' | 'command',
|
||||
filePath: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fs.promises.unlink(filePath)
|
||||
logger.debug('Plugin file deleted', { agentId, filename, type, filePath })
|
||||
} catch (error) {
|
||||
const nodeError = error as NodeJS.ErrnoException
|
||||
if (nodeError.code !== 'ENOENT') {
|
||||
throw this.toPluginError('uninstall', error)
|
||||
}
|
||||
logger.warn('Plugin file already deleted', { agentId, filename, type, filePath })
|
||||
}
|
||||
}
|
||||
|
||||
async updateFilePluginContent(agentId: string, filePath: string, content: string): Promise<string> {
|
||||
try {
|
||||
await fs.promises.access(filePath, fs.constants.W_OK)
|
||||
} catch {
|
||||
throw {
|
||||
type: 'FILE_NOT_FOUND',
|
||||
path: filePath
|
||||
} as PluginError
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.writeFile(filePath, content, 'utf8')
|
||||
logger.debug('Plugin content written successfully', {
|
||||
agentId,
|
||||
filePath,
|
||||
size: Buffer.byteLength(content, 'utf8')
|
||||
})
|
||||
} catch (error) {
|
||||
throw {
|
||||
type: 'WRITE_FAILED',
|
||||
path: filePath,
|
||||
reason: error instanceof Error ? error.message : String(error)
|
||||
} as PluginError
|
||||
}
|
||||
|
||||
return crypto.createHash('sha256').update(content).digest('hex')
|
||||
}
|
||||
|
||||
async installSkill(agentId: string, sourceAbsolutePath: string, destPath: string): Promise<void> {
|
||||
const logContext = logger.withContext('installSkill')
|
||||
let folderCopied = false
|
||||
const tempPath = `${destPath}.tmp`
|
||||
|
||||
try {
|
||||
try {
|
||||
await fs.promises.access(destPath)
|
||||
await deleteDirectoryRecursive(destPath)
|
||||
logContext.info('Removed existing skill folder', { agentId, destPath })
|
||||
} catch {
|
||||
// No existing folder
|
||||
}
|
||||
|
||||
await copyDirectoryRecursive(sourceAbsolutePath, tempPath)
|
||||
folderCopied = true
|
||||
logContext.info('Skill folder copied to temp location', { agentId, tempPath })
|
||||
|
||||
await fs.promises.rename(tempPath, destPath)
|
||||
logContext.info('Skill folder moved to final location', { agentId, destPath })
|
||||
} catch (error) {
|
||||
if (folderCopied) {
|
||||
await this.safeRemoveDirectory(tempPath, 'temp folder')
|
||||
}
|
||||
throw this.toPluginError('install-skill', error)
|
||||
}
|
||||
}
|
||||
|
||||
async uninstallSkill(agentId: string, folderName: string, skillPath: string): Promise<void> {
|
||||
const logContext = logger.withContext('uninstallSkill')
|
||||
|
||||
try {
|
||||
await deleteDirectoryRecursive(skillPath)
|
||||
logContext.info('Skill folder deleted', { agentId, folderName, skillPath })
|
||||
} catch (error) {
|
||||
const nodeError = error as NodeJS.ErrnoException
|
||||
if (nodeError.code !== 'ENOENT') {
|
||||
throw this.toPluginError('uninstall-skill', error)
|
||||
}
|
||||
logContext.warn('Skill folder already deleted', { agentId, folderName, skillPath })
|
||||
}
|
||||
}
|
||||
|
||||
private toPluginError(operation: string, error: unknown): PluginError {
|
||||
return {
|
||||
type: 'TRANSACTION_FAILED',
|
||||
operation,
|
||||
reason: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
|
||||
private async safeUnlink(targetPath: string, label: string): Promise<void> {
|
||||
try {
|
||||
await fs.promises.unlink(targetPath)
|
||||
logger.debug(`Rolled back ${label}`, { targetPath })
|
||||
} catch (unlinkError) {
|
||||
logger.error(`Failed to rollback ${label}`, {
|
||||
targetPath,
|
||||
error: unlinkError instanceof Error ? unlinkError.message : String(unlinkError)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async safeRemoveDirectory(targetPath: string, label: string): Promise<void> {
|
||||
try {
|
||||
await deleteDirectoryRecursive(targetPath)
|
||||
logger.info(`Rolled back ${label}`, { targetPath })
|
||||
} catch (unlinkError) {
|
||||
logger.error(`Failed to rollback ${label}`, {
|
||||
targetPath,
|
||||
error: unlinkError instanceof Error ? unlinkError.message : String(unlinkError)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
614
src/main/services/agents/plugins/PluginService.ts
Normal file
614
src/main/services/agents/plugins/PluginService.ts
Normal file
@ -0,0 +1,614 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { parsePluginMetadata, parseSkillMetadata } from '@main/utils/markdownParser'
|
||||
import type {
|
||||
GetAgentResponse,
|
||||
InstalledPlugin,
|
||||
InstallPluginOptions,
|
||||
ListAvailablePluginsResult,
|
||||
PluginError,
|
||||
PluginMetadata,
|
||||
PluginType,
|
||||
UninstallPluginOptions
|
||||
} from '@types'
|
||||
import { app } from 'electron'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
|
||||
import { AgentService } from '../services/AgentService'
|
||||
import { PluginCacheStore } from './PluginCacheStore'
|
||||
import { PluginInstaller } from './PluginInstaller'
|
||||
|
||||
const logger = loggerService.withContext('PluginService')
|
||||
|
||||
interface PluginServiceConfig {
|
||||
maxFileSize: number // bytes
|
||||
cacheTimeout: number // milliseconds
|
||||
}
|
||||
|
||||
/**
|
||||
* PluginService manages agent and command plugins from resources directory.
|
||||
*
|
||||
* Features:
|
||||
* - Singleton pattern for consistent state management
|
||||
* - Caching of available plugins for performance
|
||||
* - Security validation (path traversal, file size, extensions)
|
||||
* - Transactional install/uninstall operations
|
||||
* - Integration with AgentService for metadata persistence
|
||||
*/
|
||||
export class PluginService {
|
||||
private static instance: PluginService | null = null
|
||||
|
||||
private availablePluginsCache: ListAvailablePluginsResult | null = null
|
||||
private cacheTimestamp = 0
|
||||
private config: PluginServiceConfig
|
||||
private readonly cacheStore: PluginCacheStore
|
||||
private readonly installer: PluginInstaller
|
||||
private readonly agentService: AgentService
|
||||
|
||||
private readonly ALLOWED_EXTENSIONS = ['.md', '.markdown']
|
||||
|
||||
private constructor(config?: Partial<PluginServiceConfig>) {
|
||||
this.config = {
|
||||
maxFileSize: config?.maxFileSize ?? 1024 * 1024, // 1MB default
|
||||
cacheTimeout: config?.cacheTimeout ?? 5 * 60 * 1000 // 5 minutes default
|
||||
}
|
||||
this.agentService = AgentService.getInstance()
|
||||
this.cacheStore = new PluginCacheStore({
|
||||
allowedExtensions: this.ALLOWED_EXTENSIONS,
|
||||
getPluginDirectoryName: this.getPluginDirectoryName.bind(this),
|
||||
getClaudeBasePath: this.getClaudeBasePath.bind(this),
|
||||
getClaudePluginDirectory: this.getClaudePluginDirectory.bind(this),
|
||||
getPluginsBasePath: this.getPluginsBasePath.bind(this)
|
||||
})
|
||||
this.installer = new PluginInstaller()
|
||||
|
||||
logger.info('PluginService initialized', {
|
||||
maxFileSize: this.config.maxFileSize,
|
||||
cacheTimeout: this.config.cacheTimeout
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
static getInstance(config?: Partial<PluginServiceConfig>): PluginService {
|
||||
if (!PluginService.instance) {
|
||||
PluginService.instance = new PluginService(config)
|
||||
}
|
||||
return PluginService.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* List all available plugins from resources directory (with caching)
|
||||
*/
|
||||
async listAvailable(): Promise<ListAvailablePluginsResult> {
|
||||
const now = Date.now()
|
||||
|
||||
// Return cached data if still valid
|
||||
if (this.availablePluginsCache && now - this.cacheTimestamp < this.config.cacheTimeout) {
|
||||
logger.debug('Returning cached plugin list', {
|
||||
cacheAge: now - this.cacheTimestamp
|
||||
})
|
||||
return this.availablePluginsCache
|
||||
}
|
||||
|
||||
logger.info('Scanning available plugins')
|
||||
|
||||
// Scan all plugin types
|
||||
const [agents, commands, skills] = await Promise.all([
|
||||
this.cacheStore.listAvailableFilePlugins('agent'),
|
||||
this.cacheStore.listAvailableFilePlugins('command'),
|
||||
this.cacheStore.listAvailableSkills()
|
||||
])
|
||||
|
||||
const result: ListAvailablePluginsResult = {
|
||||
agents,
|
||||
commands,
|
||||
skills, // NEW: include skills
|
||||
total: agents.length + commands.length + skills.length
|
||||
}
|
||||
|
||||
// Update cache
|
||||
this.availablePluginsCache = result
|
||||
this.cacheTimestamp = now
|
||||
|
||||
logger.info('Available plugins scanned', {
|
||||
agentsCount: agents.length,
|
||||
commandsCount: commands.length,
|
||||
skillsCount: skills.length,
|
||||
total: result.total
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Install plugin with validation and transactional safety
|
||||
*/
|
||||
async install(options: InstallPluginOptions): Promise<PluginMetadata> {
|
||||
logger.info('Installing plugin', options)
|
||||
|
||||
const context = await this.prepareInstallContext(options)
|
||||
|
||||
if (options.type === 'skill') {
|
||||
return await this.installSkillPlugin(options, context)
|
||||
}
|
||||
|
||||
return await this.installFilePlugin(options, context)
|
||||
}
|
||||
|
||||
private async prepareInstallContext(options: InstallPluginOptions): Promise<{
|
||||
agent: GetAgentResponse
|
||||
workdir: string
|
||||
sourceAbsolutePath: string
|
||||
}> {
|
||||
const agent = await this.getAgentOrThrow(options.agentId)
|
||||
const workdir = this.getWorkdirOrThrow(agent, options.agentId)
|
||||
|
||||
await this.validateWorkdir(agent, workdir)
|
||||
|
||||
const sourceAbsolutePath = this.cacheStore.resolveSourcePath(options.sourcePath)
|
||||
|
||||
return { agent, workdir, sourceAbsolutePath }
|
||||
}
|
||||
|
||||
private async installSkillPlugin(
|
||||
options: InstallPluginOptions,
|
||||
context: {
|
||||
agent: GetAgentResponse
|
||||
workdir: string
|
||||
sourceAbsolutePath: string
|
||||
}
|
||||
): Promise<PluginMetadata> {
|
||||
const { agent, workdir, sourceAbsolutePath } = context
|
||||
|
||||
await this.cacheStore.ensureSkillSourceDirectory(sourceAbsolutePath, options.sourcePath)
|
||||
|
||||
const metadata = await parseSkillMetadata(sourceAbsolutePath, options.sourcePath, 'skills')
|
||||
const sanitizedFolderName = this.sanitizeFolderName(metadata.filename)
|
||||
|
||||
await this.ensureClaudeDirectory(workdir, 'skill')
|
||||
const destPath = this.getClaudePluginPath(workdir, 'skill', sanitizedFolderName)
|
||||
|
||||
metadata.filename = sanitizedFolderName
|
||||
|
||||
await this.installer.installSkill(agent.id, sourceAbsolutePath, destPath)
|
||||
|
||||
const installedAt = Date.now()
|
||||
const metadataWithInstall: PluginMetadata = {
|
||||
...metadata,
|
||||
filename: sanitizedFolderName,
|
||||
installedAt,
|
||||
updatedAt: metadata.updatedAt ?? installedAt,
|
||||
type: 'skill'
|
||||
}
|
||||
const installedPlugin: InstalledPlugin = {
|
||||
filename: sanitizedFolderName,
|
||||
type: 'skill',
|
||||
metadata: metadataWithInstall
|
||||
}
|
||||
|
||||
await this.cacheStore.upsert(workdir, installedPlugin)
|
||||
this.upsertAgentPlugin(agent, installedPlugin)
|
||||
|
||||
logger.info('Skill installed successfully', {
|
||||
agentId: options.agentId,
|
||||
sourcePath: options.sourcePath,
|
||||
folderName: sanitizedFolderName
|
||||
})
|
||||
|
||||
return metadataWithInstall
|
||||
}
|
||||
|
||||
private async installFilePlugin(
|
||||
options: InstallPluginOptions,
|
||||
context: {
|
||||
agent: GetAgentResponse
|
||||
workdir: string
|
||||
sourceAbsolutePath: string
|
||||
}
|
||||
): Promise<PluginMetadata> {
|
||||
const { agent, workdir, sourceAbsolutePath } = context
|
||||
|
||||
if (options.type === 'skill') {
|
||||
throw {
|
||||
type: 'INVALID_FILE_TYPE',
|
||||
extension: options.type
|
||||
} as PluginError
|
||||
}
|
||||
|
||||
const filePluginType: 'agent' | 'command' = options.type
|
||||
|
||||
await this.cacheStore.validatePluginFile(sourceAbsolutePath, this.config.maxFileSize)
|
||||
|
||||
const category = path.basename(path.dirname(options.sourcePath))
|
||||
const metadata = await parsePluginMetadata(sourceAbsolutePath, options.sourcePath, category, filePluginType)
|
||||
|
||||
const sanitizedFilename = this.sanitizeFilename(metadata.filename)
|
||||
metadata.filename = sanitizedFilename
|
||||
|
||||
await this.ensureClaudeDirectory(workdir, filePluginType)
|
||||
const destPath = this.getClaudePluginPath(workdir, filePluginType, sanitizedFilename)
|
||||
|
||||
await this.installer.installFilePlugin(agent.id, sourceAbsolutePath, destPath)
|
||||
|
||||
const installedAt = Date.now()
|
||||
const metadataWithInstall: PluginMetadata = {
|
||||
...metadata,
|
||||
filename: sanitizedFilename,
|
||||
installedAt,
|
||||
updatedAt: metadata.updatedAt ?? installedAt,
|
||||
type: filePluginType
|
||||
}
|
||||
const installedPlugin: InstalledPlugin = {
|
||||
filename: sanitizedFilename,
|
||||
type: filePluginType,
|
||||
metadata: metadataWithInstall
|
||||
}
|
||||
|
||||
await this.cacheStore.upsert(workdir, installedPlugin)
|
||||
this.upsertAgentPlugin(agent, installedPlugin)
|
||||
|
||||
logger.info('Plugin installed successfully', {
|
||||
agentId: options.agentId,
|
||||
filename: sanitizedFilename,
|
||||
type: filePluginType
|
||||
})
|
||||
|
||||
return metadataWithInstall
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall plugin with cleanup
|
||||
*/
|
||||
async uninstall(options: UninstallPluginOptions): Promise<void> {
|
||||
logger.info('Uninstalling plugin', options)
|
||||
|
||||
const agent = await this.getAgentOrThrow(options.agentId)
|
||||
const workdir = this.getWorkdirOrThrow(agent, options.agentId)
|
||||
|
||||
await this.validateWorkdir(agent, workdir)
|
||||
|
||||
if (options.type === 'skill') {
|
||||
const sanitizedFolderName = this.sanitizeFolderName(options.filename)
|
||||
const skillPath = this.getClaudePluginPath(workdir, 'skill', sanitizedFolderName)
|
||||
|
||||
await this.installer.uninstallSkill(agent.id, sanitizedFolderName, skillPath)
|
||||
await this.cacheStore.remove(workdir, sanitizedFolderName, 'skill')
|
||||
this.removeAgentPlugin(agent, sanitizedFolderName, 'skill')
|
||||
|
||||
logger.info('Skill uninstalled successfully', {
|
||||
agentId: options.agentId,
|
||||
folderName: sanitizedFolderName
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const sanitizedFilename = this.sanitizeFilename(options.filename)
|
||||
const filePath = this.getClaudePluginPath(workdir, options.type, sanitizedFilename)
|
||||
|
||||
await this.installer.uninstallFilePlugin(agent.id, sanitizedFilename, options.type, filePath)
|
||||
await this.cacheStore.remove(workdir, sanitizedFilename, options.type)
|
||||
this.removeAgentPlugin(agent, sanitizedFilename, options.type)
|
||||
|
||||
logger.info('Plugin uninstalled successfully', {
|
||||
agentId: options.agentId,
|
||||
filename: sanitizedFilename,
|
||||
type: options.type
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* List installed plugins for an agent (from database + filesystem validation)
|
||||
*/
|
||||
async listInstalled(agentId: string): Promise<InstalledPlugin[]> {
|
||||
logger.debug('Listing installed plugins', { agentId })
|
||||
|
||||
const agent = await this.getAgentOrThrow(agentId)
|
||||
|
||||
const workdir = agent.accessible_paths?.[0]
|
||||
|
||||
if (!workdir) {
|
||||
logger.warn('Agent has no accessible paths', { agentId })
|
||||
return []
|
||||
}
|
||||
|
||||
const plugins = await this.listInstalledFromCache(workdir)
|
||||
|
||||
logger.debug('Listed installed plugins from cache', {
|
||||
agentId,
|
||||
count: plugins.length
|
||||
})
|
||||
|
||||
return plugins
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate plugin cache (for development/testing)
|
||||
*/
|
||||
invalidateCache(): void {
|
||||
this.availablePluginsCache = null
|
||||
this.cacheTimestamp = 0
|
||||
logger.info('Plugin cache invalidated')
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cache File Management (for installed plugins)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Read cache file from .claude/plugins.json
|
||||
* Returns null if cache doesn't exist or is invalid
|
||||
*/
|
||||
|
||||
/**
|
||||
* List installed plugins from cache file
|
||||
* Falls back to filesystem scan if cache is missing or corrupt
|
||||
*/
|
||||
async listInstalledFromCache(workdir: string): Promise<InstalledPlugin[]> {
|
||||
logger.debug('Listing installed plugins from cache', { workdir })
|
||||
return await this.cacheStore.listInstalled(workdir)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read plugin content from source (resources directory)
|
||||
*/
|
||||
async readContent(sourcePath: string): Promise<string> {
|
||||
logger.info('Reading plugin content', { sourcePath })
|
||||
const content = await this.cacheStore.readSourceContent(sourcePath)
|
||||
logger.debug('Plugin content read successfully', {
|
||||
sourcePath,
|
||||
size: content.length
|
||||
})
|
||||
return content
|
||||
}
|
||||
|
||||
/**
|
||||
* Write plugin content to installed plugin (in agent's .claude directory)
|
||||
* Note: Only works for file-based plugins (agents/commands), not skills
|
||||
*/
|
||||
async writeContent(agentId: string, filename: string, type: PluginType, content: string): Promise<void> {
|
||||
logger.info('Writing plugin content', { agentId, filename, type })
|
||||
|
||||
const agent = await this.getAgentOrThrow(agentId)
|
||||
const workdir = this.getWorkdirOrThrow(agent, agentId)
|
||||
|
||||
await this.validateWorkdir(agent, workdir)
|
||||
|
||||
// Check if plugin is installed
|
||||
let installedPlugins = agent.installed_plugins ?? []
|
||||
if (installedPlugins.length === 0) {
|
||||
installedPlugins = await this.cacheStore.listInstalled(workdir)
|
||||
agent.installed_plugins = installedPlugins
|
||||
}
|
||||
const installedPlugin = installedPlugins.find((p) => p.filename === filename && p.type === type)
|
||||
|
||||
if (!installedPlugin) {
|
||||
throw {
|
||||
type: 'PLUGIN_NOT_INSTALLED',
|
||||
filename,
|
||||
agentId
|
||||
} as PluginError
|
||||
}
|
||||
|
||||
if (type === 'skill') {
|
||||
throw {
|
||||
type: 'INVALID_FILE_TYPE',
|
||||
extension: type
|
||||
} as PluginError
|
||||
}
|
||||
|
||||
const filePluginType = type as 'agent' | 'command'
|
||||
const filePath = this.getClaudePluginPath(workdir, filePluginType, filename)
|
||||
const newContentHash = await this.installer.updateFilePluginContent(agent.id, filePath, content)
|
||||
|
||||
const updatedMetadata: PluginMetadata = {
|
||||
...installedPlugin.metadata,
|
||||
contentHash: newContentHash,
|
||||
size: Buffer.byteLength(content, 'utf8'),
|
||||
updatedAt: Date.now(),
|
||||
filename,
|
||||
type: filePluginType
|
||||
}
|
||||
const updatedPlugin: InstalledPlugin = {
|
||||
filename,
|
||||
type: filePluginType,
|
||||
metadata: updatedMetadata
|
||||
}
|
||||
|
||||
await this.cacheStore.upsert(workdir, updatedPlugin)
|
||||
this.upsertAgentPlugin(agent, updatedPlugin)
|
||||
|
||||
logger.info('Plugin content updated successfully', {
|
||||
agentId,
|
||||
filename,
|
||||
type: filePluginType,
|
||||
newContentHash
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Private Helper Methods
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Resolve plugin type to directory name under .claude
|
||||
*/
|
||||
private getPluginDirectoryName(type: PluginType): 'agents' | 'commands' | 'skills' {
|
||||
if (type === 'agent') {
|
||||
return 'agents'
|
||||
}
|
||||
if (type === 'command') {
|
||||
return 'commands'
|
||||
}
|
||||
return 'skills'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the base .claude directory for a workdir
|
||||
*/
|
||||
private getClaudeBasePath(workdir: string): string {
|
||||
return path.join(workdir, '.claude')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the directory for a specific plugin type inside .claude
|
||||
*/
|
||||
private getClaudePluginDirectory(workdir: string, type: PluginType): string {
|
||||
return path.join(this.getClaudeBasePath(workdir), this.getPluginDirectoryName(type))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the absolute path for a plugin file/folder inside .claude
|
||||
*/
|
||||
private getClaudePluginPath(workdir: string, type: PluginType, filename: string): string {
|
||||
return path.join(this.getClaudePluginDirectory(workdir, type), filename)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get absolute path to plugins directory (handles packaged vs dev)
|
||||
*/
|
||||
private getPluginsBasePath(): string {
|
||||
// Use the utility function which handles both dev and production correctly
|
||||
if (app.isPackaged) {
|
||||
return path.join(process.resourcesPath, 'claude-code-plugins')
|
||||
}
|
||||
return path.join(__dirname, '../../node_modules/claude-code-plugins/plugins')
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate source path to prevent path traversal attacks
|
||||
*/
|
||||
private async getAgentOrThrow(agentId: string): Promise<GetAgentResponse> {
|
||||
const agent = await this.agentService.getAgent(agentId)
|
||||
if (!agent) {
|
||||
throw {
|
||||
type: 'INVALID_WORKDIR',
|
||||
agentId,
|
||||
workdir: '',
|
||||
message: 'Agent not found'
|
||||
} as PluginError
|
||||
}
|
||||
return agent
|
||||
}
|
||||
|
||||
private getWorkdirOrThrow(agent: GetAgentResponse, agentId: string): string {
|
||||
const workdir = agent.accessible_paths?.[0]
|
||||
if (!workdir) {
|
||||
throw {
|
||||
type: 'INVALID_WORKDIR',
|
||||
agentId,
|
||||
workdir: '',
|
||||
message: 'Agent has no accessible paths'
|
||||
} as PluginError
|
||||
}
|
||||
return workdir
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate workdir against agent's accessible paths
|
||||
*/
|
||||
private async validateWorkdir(agent: GetAgentResponse, workdir: string): Promise<void> {
|
||||
// Verify workdir is in agent's accessible_paths
|
||||
if (!agent.accessible_paths?.includes(workdir)) {
|
||||
throw {
|
||||
type: 'INVALID_WORKDIR',
|
||||
workdir,
|
||||
agentId: agent.id,
|
||||
message: 'Workdir not in agent accessible paths'
|
||||
} as PluginError
|
||||
}
|
||||
|
||||
// Verify workdir exists and is accessible
|
||||
try {
|
||||
await fs.promises.access(workdir, fs.constants.R_OK | fs.constants.W_OK)
|
||||
} catch (error) {
|
||||
throw {
|
||||
type: 'WORKDIR_NOT_FOUND',
|
||||
workdir,
|
||||
message: 'Workdir does not exist or is not accessible'
|
||||
} as PluginError
|
||||
}
|
||||
}
|
||||
|
||||
private upsertAgentPlugin(agent: GetAgentResponse, plugin: InstalledPlugin): void {
|
||||
const existing = agent.installed_plugins ?? []
|
||||
const filtered = existing.filter((p) => !(p.filename === plugin.filename && p.type === plugin.type))
|
||||
agent.installed_plugins = [...filtered, plugin]
|
||||
}
|
||||
|
||||
private removeAgentPlugin(agent: GetAgentResponse, filename: string, type: PluginType): void {
|
||||
if (!agent.installed_plugins) {
|
||||
agent.installed_plugins = []
|
||||
return
|
||||
}
|
||||
agent.installed_plugins = agent.installed_plugins.filter((p) => !(p.filename === filename && p.type === type))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize filename to remove unsafe characters (for agents/commands)
|
||||
*/
|
||||
private sanitizeFilename(filename: string): string {
|
||||
// Remove path separators
|
||||
let sanitized = filename.replace(/[/\\]/g, '_')
|
||||
// Remove null bytes using String method to avoid control-regex lint error
|
||||
sanitized = sanitized.replace(new RegExp(String.fromCharCode(0), 'g'), '')
|
||||
// Limit to safe characters (alphanumeric, dash, underscore, dot)
|
||||
sanitized = sanitized.replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||
|
||||
// Ensure .md extension
|
||||
if (!sanitized.endsWith('.md') && !sanitized.endsWith('.markdown')) {
|
||||
sanitized += '.md'
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize folder name for skills (different rules than file names)
|
||||
* NO dots allowed to avoid confusion with file extensions
|
||||
*/
|
||||
private sanitizeFolderName(folderName: string): string {
|
||||
// Remove path separators
|
||||
let sanitized = folderName.replace(/[/\\]/g, '_')
|
||||
// Remove null bytes using String method to avoid control-regex lint error
|
||||
sanitized = sanitized.replace(new RegExp(String.fromCharCode(0), 'g'), '')
|
||||
// Limit to safe characters (alphanumeric, dash, underscore)
|
||||
// NOTE: No dots allowed to avoid confusion with file extensions
|
||||
sanitized = sanitized.replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||
|
||||
// Validate no extension was provided
|
||||
if (folderName.includes('.')) {
|
||||
logger.warn('Skill folder name contained dots, sanitized', {
|
||||
original: folderName,
|
||||
sanitized
|
||||
})
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure .claude subdirectory exists for the given plugin type
|
||||
*/
|
||||
private async ensureClaudeDirectory(workdir: string, type: PluginType): Promise<void> {
|
||||
const typeDir = this.getClaudePluginDirectory(workdir, type)
|
||||
|
||||
try {
|
||||
await fs.promises.mkdir(typeDir, { recursive: true })
|
||||
logger.debug('Ensured directory exists', { typeDir })
|
||||
} catch (error) {
|
||||
logger.error('Failed to create directory', {
|
||||
typeDir,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
throw {
|
||||
type: 'PERMISSION_DENIED',
|
||||
path: typeDir
|
||||
} as PluginError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const pluginService = PluginService.getInstance()
|
||||
@ -1,5 +1,7 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { pluginService } from '@main/services/agents/plugins/PluginService'
|
||||
import { getDataPath } from '@main/utils'
|
||||
import {
|
||||
AgentBaseSchema,
|
||||
@ -17,6 +19,8 @@ import { BaseService } from '../BaseService'
|
||||
import { type AgentRow, agentsTable, type InsertAgentRow } from '../database/schema'
|
||||
import { AgentModelField } from '../errors'
|
||||
|
||||
const logger = loggerService.withContext('AgentService')
|
||||
|
||||
export class AgentService extends BaseService {
|
||||
private static instance: AgentService | null = null
|
||||
private readonly modelFields: AgentModelField[] = ['model', 'plan_model', 'small_model']
|
||||
@ -92,6 +96,24 @@ export class AgentService extends BaseService {
|
||||
|
||||
const agent = this.deserializeJsonFields(result[0]) as GetAgentResponse
|
||||
agent.tools = await this.listMcpTools(agent.type, agent.mcps)
|
||||
|
||||
// Load installed_plugins from cache file instead of database
|
||||
const workdir = agent.accessible_paths?.[0]
|
||||
if (workdir) {
|
||||
try {
|
||||
agent.installed_plugins = await pluginService.listInstalledFromCache(workdir)
|
||||
} catch (error) {
|
||||
// Log error but don't fail the request
|
||||
logger.warn(`Failed to load installed plugins for agent ${id}`, {
|
||||
workdir,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
agent.installed_plugins = []
|
||||
}
|
||||
} else {
|
||||
agent.installed_plugins = []
|
||||
}
|
||||
|
||||
return agent
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
|
||||
import { Button, ButtonGroup, Chip, ScrollShadow } from '@heroui/react'
|
||||
import { Button, Chip, ScrollShadow } from '@heroui/react'
|
||||
import { loggerService } from '@logger'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { selectPendingPermissionByToolName, toolPermissionsActions } from '@renderer/store/toolPermissions'
|
||||
@ -54,7 +54,6 @@ export function ToolPermissionRequestCard({ toolResponse }: Props) {
|
||||
const isSubmittingAllow = request?.status === 'submitting-allow'
|
||||
const isSubmittingDeny = request?.status === 'submitting-deny'
|
||||
const isSubmitting = isSubmittingAllow || isSubmittingDeny
|
||||
const hasSuggestions = (request?.suggestions?.length ?? 0) > 0
|
||||
|
||||
const handleDecision = useCallback(
|
||||
async (
|
||||
@ -147,37 +146,16 @@ export function ToolPermissionRequestCard({ toolResponse }: Props) {
|
||||
{t('agent.toolPermission.button.cancel')}
|
||||
</Button>
|
||||
|
||||
{hasSuggestions ? (
|
||||
<ButtonGroup className="h-8">
|
||||
<Button
|
||||
className="h-8 px-3"
|
||||
color="success"
|
||||
isDisabled={isSubmitting || isExpired}
|
||||
isLoading={isSubmittingAllow}
|
||||
onPress={() => handleDecision('allow')}
|
||||
startContent={<CirclePlay size={16} />}>
|
||||
{t('agent.toolPermission.button.run')}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={t('agent.toolPermission.aria.runWithOptions')}
|
||||
className="h-8 rounded-l-none"
|
||||
color="success"
|
||||
isDisabled={isSubmitting || isExpired}
|
||||
isIconOnly
|
||||
variant="solid"></Button>
|
||||
</ButtonGroup>
|
||||
) : (
|
||||
<Button
|
||||
aria-label={t('agent.toolPermission.aria.allowRequest')}
|
||||
className="h-8 px-3"
|
||||
color="success"
|
||||
isDisabled={isSubmitting || isExpired}
|
||||
isLoading={isSubmittingAllow}
|
||||
onPress={() => handleDecision('allow')}
|
||||
startContent={<CirclePlay size={16} />}>
|
||||
{t('agent.toolPermission.button.run')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
aria-label={t('agent.toolPermission.aria.allowRequest')}
|
||||
className="h-8 px-3"
|
||||
color="success"
|
||||
isDisabled={isSubmitting || isExpired}
|
||||
isLoading={isSubmittingAllow}
|
||||
onPress={() => handleDecision('allow')}
|
||||
startContent={<CirclePlay size={16} />}>
|
||||
{t('agent.toolPermission.button.run')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
aria-label={
|
||||
|
||||
@ -8,7 +8,7 @@ import { ModelMessage, TextStreamPart } from 'ai'
|
||||
import * as z from 'zod'
|
||||
|
||||
import type { Message, MessageBlock } from './newMessage'
|
||||
import { PluginMetadataSchema } from './plugin'
|
||||
import { InstalledPluginSchema, PluginMetadataSchema } from './plugin'
|
||||
|
||||
// ------------------ Core enums and helper types ------------------
|
||||
export const PermissionModeSchema = z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan'])
|
||||
@ -58,30 +58,7 @@ export const AgentConfigurationSchema = z
|
||||
|
||||
// https://docs.claude.com/en/docs/claude-code/sdk/sdk-permissions#mode-specific-behaviors
|
||||
permission_mode: PermissionModeSchema.optional().default('default'), // Permission mode, default to 'default'
|
||||
max_turns: z.number().optional().default(100), // Maximum number of interaction turns, default to 100
|
||||
|
||||
// Plugin metadata
|
||||
installed_plugins: z
|
||||
.array(
|
||||
z.object({
|
||||
sourcePath: z.string(), // Full source path for re-install/updates
|
||||
filename: z.string(), // Destination filename (unique)
|
||||
type: z.enum(['agent', 'command', 'skill']),
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
allowed_tools: z.array(z.string()).optional(),
|
||||
tools: z.array(z.string()).optional(),
|
||||
category: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
version: z.string().optional(),
|
||||
author: z.string().optional(),
|
||||
contentHash: z.string(), // Detect file modifications
|
||||
installedAt: z.number(), // Track installation time
|
||||
updatedAt: z.number().optional() // Track updates
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
.default([])
|
||||
max_turns: z.number().optional().default(100) // Maximum number of interaction turns, default to 100
|
||||
})
|
||||
.loose()
|
||||
|
||||
@ -264,7 +241,8 @@ export interface UpdateAgentRequest extends Partial<AgentBase> {}
|
||||
export type ReplaceAgentRequest = AgentBase
|
||||
|
||||
export const GetAgentResponseSchema = AgentEntitySchema.extend({
|
||||
tools: z.array(ToolSchema).optional() // All tools available to the agent (including built-in and custom)
|
||||
tools: z.array(ToolSchema).optional(), // All tools available to the agent (including built-in and custom)
|
||||
installed_plugins: z.array(InstalledPluginSchema).optional() // Plugins loaded from .claude/plugins.json cache
|
||||
})
|
||||
|
||||
export type GetAgentResponse = z.infer<typeof GetAgentResponseSchema>
|
||||
|
||||
@ -43,6 +43,15 @@ export const InstalledPluginSchema = z.object({
|
||||
|
||||
export type InstalledPlugin = z.infer<typeof InstalledPluginSchema>
|
||||
|
||||
// Cache file schema for .claude/plugins.json
|
||||
export const CachedPluginsDataSchema = z.object({
|
||||
version: z.number().default(1),
|
||||
lastUpdated: z.number(), // Unix timestamp in milliseconds
|
||||
plugins: z.array(InstalledPluginSchema)
|
||||
})
|
||||
|
||||
export type CachedPluginsData = z.infer<typeof CachedPluginsDataSchema>
|
||||
|
||||
// Error handling types
|
||||
export type PluginError =
|
||||
| { type: 'PATH_TRAVERSAL'; message: string; path: string }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user