diff --git a/.github/workflows/issue-management.yml b/.github/workflows/issue-management.yml index 89ccc1fa8d..f6041f2336 100644 --- a/.github/workflows/issue-management.yml +++ b/.github/workflows/issue-management.yml @@ -29,8 +29,10 @@ jobs: days-before-close: 0 # Close immediately after stale stale-issue-label: 'inactive' close-issue-label: 'closed:no-response' + exempt-all-milestones: true + exempt-all-assignees: true stale-issue-message: | - This issue has been labeled as needing more information and has been inactive for ${{ env.daysBeforeStale }} days. + This issue has been labeled as needing more information and has been inactive for ${{ env.daysBeforeStale }} days. It will be closed now due to lack of additional information. 该问题被标记为"需要更多信息"且已经 ${{ env.daysBeforeStale }} 天没有任何活动,将立即关闭。 @@ -46,6 +48,8 @@ jobs: days-before-stale: ${{ env.daysBeforeStale }} days-before-close: ${{ env.daysBeforeClose }} stale-issue-label: 'inactive' + exempt-all-milestones: true + exempt-all-assignees: true stale-issue-message: | This issue has been inactive for a prolonged period and will be closed automatically in ${{ env.daysBeforeClose }} days. 该问题已长时间处于闲置状态,${{ env.daysBeforeClose }} 天后将自动关闭。 diff --git a/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.1-d937b73fed.patch b/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.25-08bbabb5d3.patch similarity index 75% rename from .yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.1-d937b73fed.patch rename to .yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.25-08bbabb5d3.patch index 5e37489f25..057443aa43 100644 --- a/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.1-d937b73fed.patch +++ b/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.25-08bbabb5d3.patch @@ -1,24 +1,24 @@ diff --git a/sdk.mjs b/sdk.mjs -index 461e9a2ba246778261108a682762ffcf26f7224e..44bd667d9f591969d36a105ba5eb8b478c738dd8 100644 +index 10162e5b1624f8ce667768943347a6e41089ad2f..32568ae08946590e382270c88d85fba81187568e 100755 --- a/sdk.mjs +++ b/sdk.mjs -@@ -6215,7 +6215,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) { +@@ -6213,7 +6213,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) { } - + // ../src/transport/ProcessTransport.ts -import { spawn } from "child_process"; +import { fork } from "child_process"; import { createInterface } from "readline"; - + // ../src/utils/fsOperations.ts -@@ -6473,14 +6473,11 @@ class ProcessTransport { +@@ -6487,14 +6487,11 @@ class ProcessTransport { const errorMessage = isNativeBinary(pathToClaudeCodeExecutable) ? `Claude Code native binary not found at ${pathToClaudeCodeExecutable}. Please ensure Claude Code is installed via native installer or specify a valid path with options.pathToClaudeCodeExecutable.` : `Claude Code executable not found at ${pathToClaudeCodeExecutable}. Is options.pathToClaudeCodeExecutable set?`; throw new ReferenceError(errorMessage); } - const isNative = isNativeBinary(pathToClaudeCodeExecutable); - const spawnCommand = isNative ? pathToClaudeCodeExecutable : executable; -- const spawnArgs = isNative ? args : [...executableArgs, pathToClaudeCodeExecutable, ...args]; -- this.logForDebugging(isNative ? `Spawning Claude Code native binary: ${pathToClaudeCodeExecutable} ${args.join(" ")}` : `Spawning Claude Code process: ${executable} ${[...executableArgs, pathToClaudeCodeExecutable, ...args].join(" ")}`); +- const spawnArgs = isNative ? [...executableArgs, ...args] : [...executableArgs, pathToClaudeCodeExecutable, ...args]; +- this.logForDebugging(isNative ? `Spawning Claude Code native binary: ${spawnCommand} ${spawnArgs.join(" ")}` : `Spawning Claude Code process: ${spawnCommand} ${spawnArgs.join(" ")}`); + this.logForDebugging(`Forking Claude Code Node.js process: ${pathToClaudeCodeExecutable} ${args.join(" ")}`); const stderrMode = env.DEBUG || stderr ? "pipe" : "ignore"; - this.child = spawn(spawnCommand, spawnArgs, { diff --git a/CLAUDE.md b/CLAUDE.md index 0eaf1a3aaf..159c4be9e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,8 +10,9 @@ This file provides guidance to AI coding assistants when working with code in th - **Build with HeroUI**: Use HeroUI for every new UI component; never add `antd` or `styled-components`. - **Log centrally**: Route all logging through `loggerService` with the right context—no `console.log`. - **Research via subagent**: Lean on `subagent` for external docs, APIs, news, and references. -- **Seek review**: Ask a human developer to review substantial changes before merging. -- **Commit in rhythm**: Keep commits small, conventional, and emoji-tagged. +- **Always propose before executing**: Before making any changes, clearly explain your planned approach and wait for explicit user approval to ensure alignment and prevent unwanted modifications. +- **Write conventional commits with emoji**: Commit small, focused changes using emoji-prefixed Conventional Commit messages (e.g., `✨ feat:`, `🐛 fix:`, `♻️ refactor:`, ` +📝 docs:`). ## Development Commands diff --git a/biome.jsonc b/biome.jsonc index 03e93ed0a3..5829a69155 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -21,7 +21,11 @@ "quoteStyle": "single" } }, - "files": { "ignoreUnknown": false }, + "files": { + "ignoreUnknown": false, + "includes": ["**"], + "maxSize": 2097152 + }, "formatter": { "attributePosition": "auto", "bracketSameLine": false, diff --git a/electron-builder.yml b/electron-builder.yml index 5a42226b17..2f2f6a21ea 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -67,6 +67,10 @@ asarUnpack: extraResources: - from: "migrations/sqlite-drizzle" to: "migrations/sqlite-drizzle" + # copy from node_modules/claude-code-plugins/plugins to resources/data/claude-code-pluginso + - from: "./node_modules/claude-code-plugins/plugins/" + to: "claude-code-plugins" + win: executableName: Cherry Studio artifactName: ${productName}-${version}-${arch}-setup.${ext} diff --git a/package.json b/package.json index 6839a0a75b..73f6aa45ff 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core npm publish --access public" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.1#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.1-d937b73fed.patch", + "@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.25#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.25-08bbabb5d3.patch", "@libsql/client": "0.14.0", "@libsql/win32-x64-msvc": "^0.4.7", "@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch", @@ -89,6 +89,8 @@ "express": "^5.1.0", "font-list": "^2.0.0", "graceful-fs": "^4.2.11", + "gray-matter": "^4.0.3", + "js-yaml": "^4.1.0", "jsdom": "26.1.0", "node-stream-zip": "^1.15.0", "officeparser": "^4.2.0", @@ -198,6 +200,7 @@ "@types/fs-extra": "^11", "@types/he": "^1", "@types/html-to-text": "^9", + "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.17.5", "@types/markdown-it": "^14", "@types/md5": "^2.3.5", @@ -237,6 +240,7 @@ "check-disk-space": "3.4.0", "cheerio": "^1.1.2", "chokidar": "^4.0.3", + "claude-code-plugins": "1.0.1", "cli-progress": "^3.12.0", "clsx": "^2.1.1", "code-inspector-plugin": "^0.20.14", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index ba48878bb5..a5a9f54f9b 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -96,6 +96,10 @@ export enum IpcChannel { AgentMessage_PersistExchange = 'agent-message:persist-exchange', AgentMessage_GetHistory = 'agent-message:get-history', + AgentToolPermission_Request = 'agent-tool-permission:request', + AgentToolPermission_Response = 'agent-tool-permission:response', + AgentToolPermission_Result = 'agent-tool-permission:result', + //copilot Copilot_GetAuthMessage = 'copilot:get-auth-message', Copilot_GetCopilotToken = 'copilot:get-copilot-token', @@ -382,5 +386,14 @@ export enum IpcChannel { Ovms_StopOVMS = 'ovms:stop-ovms', // CherryAI - Cherryai_GetSignature = 'cherryai:get-signature' + Cherryai_GetSignature = 'cherryai:get-signature', + + // Claude Code Plugins + ClaudeCodePlugin_ListAvailable = 'claudeCodePlugin:list-available', + ClaudeCodePlugin_Install = 'claudeCodePlugin:install', + ClaudeCodePlugin_Uninstall = 'claudeCodePlugin:uninstall', + ClaudeCodePlugin_ListInstalled = 'claudeCodePlugin:list-installed', + ClaudeCodePlugin_InvalidateCache = 'claudeCodePlugin:invalidate-cache', + ClaudeCodePlugin_ReadContent = 'claudeCodePlugin:read-content', + ClaudeCodePlugin_WriteContent = 'claudeCodePlugin:write-content' } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index ac8d469b09..709bf24834 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -19,6 +19,7 @@ import type { FileMetadata, Notification, OcrProvider, + PluginError, Provider, Shortcut, SupportedOcrFile @@ -49,6 +50,7 @@ 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' @@ -95,6 +97,18 @@ const vertexAIService = VertexAIService.getInstance() const memoryService = MemoryService.getInstance() const dxtService = new DxtService() const ovmsManager = new OvmsManager() +const pluginService = PluginService.getInstance() + +function normalizeError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} + +function extractPluginError(error: unknown): PluginError | null { + if (error && typeof error === 'object' && 'type' in error && typeof (error as { type: unknown }).type === 'string') { + return error as PluginError + } + return null +} export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { const appUpdater = new AppUpdater() @@ -894,6 +908,119 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { // CherryAI ipcMain.handle(IpcChannel.Cherryai_GetSignature, (_, params) => generateSignature(params)) + // Claude Code Plugins + ipcMain.handle(IpcChannel.ClaudeCodePlugin_ListAvailable, async () => { + try { + const data = await pluginService.listAvailable() + return { success: true, data } + } catch (error) { + const pluginError = extractPluginError(error) + if (pluginError) { + logger.error('Failed to list available plugins', pluginError) + return { success: false, error: pluginError } + } + + const err = normalizeError(error) + logger.error('Failed to list available plugins', err) + return { + success: false, + error: { + type: 'TRANSACTION_FAILED', + operation: 'list-available', + reason: err.message + } + } + } + }) + + ipcMain.handle(IpcChannel.ClaudeCodePlugin_Install, async (_, options) => { + try { + const data = await pluginService.install(options) + return { success: true, data } + } catch (error) { + logger.error('Failed to install plugin', { options, error }) + return { success: false, error } + } + }) + + ipcMain.handle(IpcChannel.ClaudeCodePlugin_Uninstall, async (_, options) => { + try { + await pluginService.uninstall(options) + return { success: true, data: undefined } + } catch (error) { + logger.error('Failed to uninstall plugin', { options, error }) + return { success: false, error } + } + }) + + ipcMain.handle(IpcChannel.ClaudeCodePlugin_ListInstalled, async (_, agentId: string) => { + try { + const data = await pluginService.listInstalled(agentId) + return { success: true, data } + } catch (error) { + const pluginError = extractPluginError(error) + if (pluginError) { + logger.error('Failed to list installed plugins', { agentId, error: pluginError }) + return { success: false, error: pluginError } + } + + const err = normalizeError(error) + logger.error('Failed to list installed plugins', { agentId, error: err }) + return { + success: false, + error: { + type: 'TRANSACTION_FAILED', + operation: 'list-installed', + reason: err.message + } + } + } + }) + + ipcMain.handle(IpcChannel.ClaudeCodePlugin_InvalidateCache, async () => { + try { + pluginService.invalidateCache() + return { success: true, data: undefined } + } catch (error) { + const pluginError = extractPluginError(error) + if (pluginError) { + logger.error('Failed to invalidate plugin cache', pluginError) + return { success: false, error: pluginError } + } + + const err = normalizeError(error) + logger.error('Failed to invalidate plugin cache', err) + return { + success: false, + error: { + type: 'TRANSACTION_FAILED', + operation: 'invalidate-cache', + reason: err.message + } + } + } + }) + + ipcMain.handle(IpcChannel.ClaudeCodePlugin_ReadContent, async (_, sourcePath: string) => { + try { + const data = await pluginService.readContent(sourcePath) + return { success: true, data } + } catch (error) { + logger.error('Failed to read plugin content', { sourcePath, error }) + return { success: false, error } + } + }) + + ipcMain.handle(IpcChannel.ClaudeCodePlugin_WriteContent, async (_, options) => { + try { + await pluginService.writeContent(options.agentId, options.filename, options.type, options.content) + return { success: true, data: undefined } + } catch (error) { + logger.error('Failed to write plugin content', { options, error }) + return { success: false, error } + } + }) + // Preference handlers PreferenceService.registerIpcHandler() } diff --git a/src/main/knowledge/preprocess/OpenMineruPreprocessProvider.ts b/src/main/knowledge/preprocess/OpenMineruPreprocessProvider.ts new file mode 100644 index 0000000000..9a3bca65a1 --- /dev/null +++ b/src/main/knowledge/preprocess/OpenMineruPreprocessProvider.ts @@ -0,0 +1,199 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { loggerService } from '@logger' +import { fileStorage } from '@main/services/FileStorage' +import type { FileMetadata, PreprocessProvider } from '@types' +import AdmZip from 'adm-zip' +import { net } from 'electron' +import FormData from 'form-data' + +import BasePreprocessProvider from './BasePreprocessProvider' + +const logger = loggerService.withContext('MineruPreprocessProvider') + +export default class OpenMineruPreprocessProvider extends BasePreprocessProvider { + constructor(provider: PreprocessProvider, userId?: string) { + super(provider, userId) + } + + public async parseFile( + sourceId: string, + file: FileMetadata + ): Promise<{ processedFile: FileMetadata; quota: number }> { + try { + const filePath = fileStorage.getFilePathById(file) + logger.info(`Open MinerU preprocess processing started: ${filePath}`) + await this.validateFile(filePath) + + // 1. Update progress + await this.sendPreprocessProgress(sourceId, 50) + logger.info(`File ${file.name} is starting processing...`) + + // 2. Upload file and extract + const { path: outputPath } = await this.uploadFileAndExtract(file) + + // 3. Check quota + const quota = await this.checkQuota() + + // 4. Create processed file info + return { + processedFile: this.createProcessedFileInfo(file, outputPath), + quota + } + } catch (error) { + logger.error(`Open MinerU preprocess processing failed for:`, error as Error) + throw error + } + } + + public async checkQuota() { + // self-hosted version always has enough quota + return Infinity + } + + private async validateFile(filePath: string): Promise { + const pdfBuffer = await fs.promises.readFile(filePath) + + const doc = await this.readPdf(pdfBuffer) + + // File page count must be less than 600 pages + if (doc.numPages >= 600) { + throw new Error(`PDF page count (${doc.numPages}) exceeds the limit of 600 pages`) + } + // File size must be less than 200MB + if (pdfBuffer.length >= 200 * 1024 * 1024) { + const fileSizeMB = Math.round(pdfBuffer.length / (1024 * 1024)) + throw new Error(`PDF file size (${fileSizeMB}MB) exceeds the limit of 200MB`) + } + } + + private createProcessedFileInfo(file: FileMetadata, outputPath: string): FileMetadata { + // Find the main file after extraction + let finalPath = '' + let finalName = file.origin_name.replace('.pdf', '.md') + // Find the corresponding folder by file name + outputPath = path.join(outputPath, `${file.origin_name.replace('.pdf', '')}`) + try { + const files = fs.readdirSync(outputPath) + + const mdFile = files.find((f) => f.endsWith('.md')) + if (mdFile) { + const originalMdPath = path.join(outputPath, mdFile) + const newMdPath = path.join(outputPath, finalName) + + // Rename file to original file name + try { + fs.renameSync(originalMdPath, newMdPath) + finalPath = newMdPath + logger.info(`Renamed markdown file from ${mdFile} to ${finalName}`) + } catch (renameError) { + logger.warn(`Failed to rename file ${mdFile} to ${finalName}: ${renameError}`) + // If rename fails, use the original file + finalPath = originalMdPath + finalName = mdFile + } + } + } catch (error) { + logger.warn(`Failed to read output directory ${outputPath}:`, error as Error) + finalPath = path.join(outputPath, `${file.id}.md`) + } + + return { + ...file, + name: finalName, + path: finalPath, + ext: '.md', + size: fs.existsSync(finalPath) ? fs.statSync(finalPath).size : 0 + } + } + + private async uploadFileAndExtract( + file: FileMetadata, + maxRetries: number = 5, + intervalMs: number = 5000 + ): Promise<{ path: string }> { + let retries = 0 + + const endpoint = `${this.provider.apiHost}/file_parse` + + // Get file stream + const filePath = fileStorage.getFilePathById(file) + const fileBuffer = await fs.promises.readFile(filePath) + + const formData = new FormData() + formData.append('return_md', 'true') + formData.append('response_format_zip', 'true') + formData.append('files', fileBuffer, { + filename: file.origin_name + }) + + while (retries < maxRetries) { + let zipPath: string | undefined + + try { + const response = await net.fetch(endpoint, { + method: 'POST', + headers: { + token: this.userId ?? '', + ...(this.provider.apiKey ? { Authorization: `Bearer ${this.provider.apiKey}` } : {}), + ...formData.getHeaders() + }, + body: formData.getBuffer() + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + // Check if response header is application/zip + if (response.headers.get('content-type') !== 'application/zip') { + throw new Error(`Downloaded ZIP file has unexpected content-type: ${response.headers.get('content-type')}`) + } + + const dirPath = this.storageDir + + zipPath = path.join(dirPath, `${file.id}.zip`) + const extractPath = path.join(dirPath, `${file.id}`) + + const arrayBuffer = await response.arrayBuffer() + fs.writeFileSync(zipPath, Buffer.from(arrayBuffer)) + logger.info(`Downloaded ZIP file: ${zipPath}`) + + // Ensure extraction directory exists + if (!fs.existsSync(extractPath)) { + fs.mkdirSync(extractPath, { recursive: true }) + } + + // Extract files + const zip = new AdmZip(zipPath) + zip.extractAllTo(extractPath, true) + logger.info(`Extracted files to: ${extractPath}`) + + return { path: extractPath } + } catch (error) { + logger.warn( + `Failed to upload and extract file: ${(error as Error).message}, retry ${retries + 1}/${maxRetries}` + ) + if (retries === maxRetries - 1) { + throw error + } + } finally { + // Delete temporary ZIP file + if (zipPath && fs.existsSync(zipPath)) { + try { + fs.unlinkSync(zipPath) + logger.info(`Deleted temporary ZIP file: ${zipPath}`) + } catch (deleteError) { + logger.warn(`Failed to delete temporary ZIP file ${zipPath}:`, deleteError as Error) + } + } + } + + retries++ + await new Promise((resolve) => setTimeout(resolve, intervalMs)) + } + + throw new Error(`Processing timeout for file: ${file.id}`) + } +} diff --git a/src/main/knowledge/preprocess/PreprocessProviderFactory.ts b/src/main/knowledge/preprocess/PreprocessProviderFactory.ts index dce1cef047..94d4e70d5a 100644 --- a/src/main/knowledge/preprocess/PreprocessProviderFactory.ts +++ b/src/main/knowledge/preprocess/PreprocessProviderFactory.ts @@ -5,6 +5,7 @@ import DefaultPreprocessProvider from './DefaultPreprocessProvider' import Doc2xPreprocessProvider from './Doc2xPreprocessProvider' import MineruPreprocessProvider from './MineruPreprocessProvider' import MistralPreprocessProvider from './MistralPreprocessProvider' +import OpenMineruPreprocessProvider from './OpenMineruPreprocessProvider' export default class PreprocessProviderFactory { static create(provider: PreprocessProvider, userId?: string): BasePreprocessProvider { switch (provider.id) { @@ -14,6 +15,8 @@ export default class PreprocessProviderFactory { return new MistralPreprocessProvider(provider) case 'mineru': return new MineruPreprocessProvider(provider, userId) + case 'open-mineru': + return new OpenMineruPreprocessProvider(provider, userId) default: return new DefaultPreprocessProvider(provider) } diff --git a/src/main/services/PluginService.ts b/src/main/services/PluginService.ts new file mode 100644 index 0000000000..8ff1820208 --- /dev/null +++ b/src/main/services/PluginService.ts @@ -0,0 +1,1171 @@ +import { loggerService } from '@logger' +import { copyDirectoryRecursive, deleteDirectoryRecursive } from '@main/utils/fileOperations' +import { findAllSkillDirectories, parsePluginMetadata, parseSkillMetadata } from '@main/utils/markdownParser' +import type { + AgentEntity, + InstalledPlugin, + InstallPluginOptions, + ListAvailablePluginsResult, + PluginError, + PluginMetadata, + PluginType, + UninstallPluginOptions +} from '@types' +import * as crypto from 'crypto' +import { app } from 'electron' +import * as fs from 'fs' +import * as path from 'path' + +import { AgentService } from './agents/services/AgentService' + +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 ALLOWED_EXTENSIONS = ['.md', '.markdown'] + + private constructor(config?: Partial) { + this.config = { + maxFileSize: config?.maxFileSize ?? 1024 * 1024, // 1MB default + cacheTimeout: config?.cacheTimeout ?? 5 * 60 * 1000 // 5 minutes default + } + + logger.info('PluginService initialized', { + maxFileSize: this.config.maxFileSize, + cacheTimeout: this.config.cacheTimeout + }) + } + + /** + * Get singleton instance + */ + static getInstance(config?: Partial): PluginService { + if (!PluginService.instance) { + PluginService.instance = new PluginService(config) + } + return PluginService.instance + } + + /** + * List all available plugins from resources directory (with caching) + */ + async listAvailable(): Promise { + 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.scanPluginDirectory('agent'), + this.scanPluginDirectory('command'), + this.scanSkillDirectory() + ]) + + 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 { + logger.info('Installing plugin', options) + + // Validate source path + this.validateSourcePath(options.sourcePath) + + // Get agent and validate + const agent = await AgentService.getInstance().getAgent(options.agentId) + if (!agent) { + throw { + type: 'INVALID_WORKDIR', + agentId: options.agentId, + workdir: '', + message: 'Agent not found' + } as PluginError + } + + const workdir = agent.accessible_paths?.[0] + if (!workdir) { + throw { + type: 'INVALID_WORKDIR', + agentId: options.agentId, + workdir: '', + message: 'Agent has no accessible paths' + } as PluginError + } + + await this.validateWorkdir(workdir, options.agentId) + + // Get absolute source path + const basePath = this.getPluginsBasePath() + const sourceAbsolutePath = path.join(basePath, options.sourcePath) + + // BRANCH: Handle skills differently than files + if (options.type === 'skill') { + // Validate skill folder exists and is a directory + try { + const stats = await fs.promises.stat(sourceAbsolutePath) + if (!stats.isDirectory()) { + throw { + type: 'INVALID_METADATA', + reason: 'Skill source is not a directory', + path: options.sourcePath + } as PluginError + } + } catch (error) { + throw { + type: 'FILE_NOT_FOUND', + path: sourceAbsolutePath + } as PluginError + } + + // Parse metadata from SKILL.md + const metadata = await parseSkillMetadata(sourceAbsolutePath, options.sourcePath, 'skills') + + // Sanitize folder name (different rules than file names) + const sanitizedFolderName = this.sanitizeFolderName(metadata.filename) + + // Ensure .claude/skills directory exists + await this.ensureClaudeDirectory(workdir, 'skill') + + // Construct destination path (folder, not file) + const destPath = path.join(workdir, '.claude', 'skills', sanitizedFolderName) + + // Update metadata with sanitized folder name + metadata.filename = sanitizedFolderName + + // Execute skill-specific install + await this.installSkill(agent, sourceAbsolutePath, destPath, metadata) + + logger.info('Skill installed successfully', { + agentId: options.agentId, + sourcePath: options.sourcePath, + folderName: sanitizedFolderName + }) + + return { + ...metadata, + installedAt: Date.now() + } + } + + // EXISTING LOGIC for agents/commands (unchanged) + // Files go through existing validation and sanitization + await this.validatePluginFile(sourceAbsolutePath) + + // Parse metadata + const category = path.basename(path.dirname(options.sourcePath)) + const metadata = await parsePluginMetadata(sourceAbsolutePath, options.sourcePath, category, options.type) + + // Sanitize filename + const sanitizedFilename = this.sanitizeFilename(metadata.filename) + + // Ensure .claude directory exists + await this.ensureClaudeDirectory(workdir, options.type) + + // Get destination path + const destDir = path.join(workdir, '.claude', options.type === 'agent' ? 'agents' : 'commands') + const destPath = path.join(destDir, sanitizedFilename) + + // Check for duplicate and auto-uninstall if exists + const existingPlugins = agent.configuration?.installed_plugins || [] + const existingPlugin = existingPlugins.find((p) => p.filename === sanitizedFilename && p.type === options.type) + + if (existingPlugin) { + logger.info('Plugin already installed, auto-uninstalling old version', { + filename: sanitizedFilename + }) + await this.uninstallTransaction(agent, sanitizedFilename, options.type) + + // Re-fetch agent after uninstall + const updatedAgent = await AgentService.getInstance().getAgent(options.agentId) + if (!updatedAgent) { + throw { + type: 'TRANSACTION_FAILED', + operation: 'install', + reason: 'Agent not found after uninstall' + } as PluginError + } + + await this.installTransaction(updatedAgent, sourceAbsolutePath, destPath, metadata) + } else { + await this.installTransaction(agent, sourceAbsolutePath, destPath, metadata) + } + + logger.info('Plugin installed successfully', { + agentId: options.agentId, + filename: sanitizedFilename, + type: options.type + }) + + return { + ...metadata, + filename: sanitizedFilename, + installedAt: Date.now() + } + } + + /** + * Uninstall plugin with cleanup + */ + async uninstall(options: UninstallPluginOptions): Promise { + logger.info('Uninstalling plugin', options) + + // Get agent + const agent = await AgentService.getInstance().getAgent(options.agentId) + if (!agent) { + throw { + type: 'INVALID_WORKDIR', + agentId: options.agentId, + workdir: '', + message: 'Agent not found' + } as PluginError + } + + // BRANCH: Handle skills differently than files + if (options.type === 'skill') { + // For skills, filename is the folder name (no extension) + // Use sanitizeFolderName to ensure consistency + const sanitizedFolderName = this.sanitizeFolderName(options.filename) + await this.uninstallSkill(agent, sanitizedFolderName) + + logger.info('Skill uninstalled successfully', { + agentId: options.agentId, + folderName: sanitizedFolderName + }) + + return + } + + // EXISTING LOGIC for agents/commands (unchanged) + // For files, filename includes .md extension + const sanitizedFilename = this.sanitizeFilename(options.filename) + await this.uninstallTransaction(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 { + logger.debug('Listing installed plugins', { agentId }) + + // Get agent + const agent = await AgentService.getInstance().getAgent(agentId) + if (!agent) { + throw { + type: 'INVALID_WORKDIR', + agentId, + workdir: '', + message: 'Agent not found' + } as PluginError + } + + const installedPlugins = agent.configuration?.installed_plugins || [] + const workdir = agent.accessible_paths?.[0] + + if (!workdir) { + logger.warn('Agent has no accessible paths', { agentId }) + return [] + } + + // Validate each plugin still exists on filesystem + const validatedPlugins: InstalledPlugin[] = [] + + for (const plugin of installedPlugins) { + // Get plugin path based on type + let pluginPath: string + if (plugin.type === 'skill') { + pluginPath = path.join(workdir, '.claude', 'skills', plugin.filename) + } else { + pluginPath = path.join(workdir, '.claude', plugin.type === 'agent' ? 'agents' : 'commands', plugin.filename) + } + + try { + const stats = await fs.promises.stat(pluginPath) + + // For files (agents/commands), verify file hash if stored + if (plugin.type !== 'skill' && plugin.contentHash) { + const currentHash = await this.calculateFileHash(pluginPath) + if (currentHash !== plugin.contentHash) { + logger.warn('Plugin file hash mismatch', { + filename: plugin.filename, + expected: plugin.contentHash, + actual: currentHash + }) + } + } + + // For skills, stats.size is folder size (handled differently) + // For files, stats.size is file size + validatedPlugins.push({ + filename: plugin.filename, + type: plugin.type, + metadata: { + sourcePath: plugin.sourcePath, + filename: plugin.filename, + name: plugin.name, + description: plugin.description, + allowed_tools: plugin.allowed_tools, + tools: plugin.tools, + category: plugin.category || '', + type: plugin.type, + tags: plugin.tags, + version: plugin.version, + author: plugin.author, + size: stats.size, + contentHash: plugin.contentHash, + installedAt: plugin.installedAt, + updatedAt: plugin.updatedAt + } + }) + } catch (error) { + logger.warn('Plugin not found on filesystem', { + filename: plugin.filename, + path: pluginPath, + error: error instanceof Error ? error.message : String(error) + }) + } + } + + logger.debug('Listed installed plugins', { + agentId, + count: validatedPlugins.length + }) + + return validatedPlugins + } + + /** + * Invalidate plugin cache (for development/testing) + */ + invalidateCache(): void { + this.availablePluginsCache = null + this.cacheTimestamp = 0 + logger.info('Plugin cache invalidated') + } + + /** + * Read plugin content from source (resources directory) + */ + async readContent(sourcePath: string): Promise { + logger.info('Reading plugin content', { sourcePath }) + + // Validate source path + this.validateSourcePath(sourcePath) + + // Get absolute path + const basePath = this.getPluginsBasePath() + const absolutePath = path.join(basePath, sourcePath) + + // Validate file exists and is accessible + try { + await fs.promises.access(absolutePath, fs.constants.R_OK) + } catch (error) { + throw { + type: 'FILE_NOT_FOUND', + path: sourcePath + } as PluginError + } + + // Read content + try { + const content = await fs.promises.readFile(absolutePath, 'utf8') + logger.debug('Plugin content read successfully', { + sourcePath, + size: content.length + }) + return content + } catch (error) { + throw { + type: 'READ_FAILED', + path: sourcePath, + reason: error instanceof Error ? error.message : String(error) + } as PluginError + } + } + + /** + * 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 { + logger.info('Writing plugin content', { agentId, filename, type }) + + // Get agent + const agent = await AgentService.getInstance().getAgent(agentId) + if (!agent) { + throw { + type: 'INVALID_WORKDIR', + agentId, + workdir: '', + message: 'Agent not found' + } as PluginError + } + + const workdir = agent.accessible_paths?.[0] + if (!workdir) { + throw { + type: 'INVALID_WORKDIR', + agentId, + workdir: '', + message: 'Agent has no accessible paths' + } as PluginError + } + + // Check if plugin is installed + const installedPlugins = agent.configuration?.installed_plugins || [] + const installedPlugin = installedPlugins.find((p) => p.filename === filename && p.type === type) + + if (!installedPlugin) { + throw { + type: 'PLUGIN_NOT_INSTALLED', + filename, + agentId + } as PluginError + } + + // Get file path + const filePath = path.join(workdir, '.claude', type === 'agent' ? 'agents' : 'commands', filename) + + // Verify file exists + try { + await fs.promises.access(filePath, fs.constants.W_OK) + } catch (error) { + throw { + type: 'FILE_NOT_FOUND', + path: filePath + } as PluginError + } + + // Write content + try { + await fs.promises.writeFile(filePath, content, 'utf8') + logger.debug('Plugin content written successfully', { + filePath, + size: content.length + }) + + // Update content hash in database + const newContentHash = crypto.createHash('sha256').update(content).digest('hex') + const updatedPlugins = installedPlugins.map((p) => { + if (p.filename === filename && p.type === type) { + return { + ...p, + contentHash: newContentHash, + updatedAt: Date.now() + } + } + return p + }) + + await AgentService.getInstance().updateAgent(agentId, { + configuration: { + permission_mode: 'default', + max_turns: 100, + ...agent.configuration, + installed_plugins: updatedPlugins + } + }) + + logger.info('Plugin content updated successfully', { + agentId, + filename, + type, + newContentHash + }) + } catch (error) { + throw { + type: 'WRITE_FAILED', + path: filePath, + reason: error instanceof Error ? error.message : String(error) + } as PluginError + } + } + + // ============================================================================ + // Private Helper Methods + // ============================================================================ + + /** + * 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') + } + + /** + * Scan plugin directory and return metadata for all plugins + */ + private async scanPluginDirectory(type: 'agent' | 'command'): Promise { + const basePath = this.getPluginsBasePath() + const typeDir = path.join(basePath, type === 'agent' ? 'agents' : 'commands') + + try { + await fs.promises.access(typeDir, fs.constants.R_OK) + } catch (error) { + logger.warn(`Plugin directory not accessible: ${typeDir}`, { + error: error instanceof Error ? error.message : String(error) + }) + return [] + } + + const plugins: PluginMetadata[] = [] + const categories = await fs.promises.readdir(typeDir, { withFileTypes: true }) + + for (const categoryEntry of categories) { + if (!categoryEntry.isDirectory()) { + continue + } + + const category = categoryEntry.name + const categoryPath = path.join(typeDir, 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.ALLOWED_EXTENSIONS.includes(ext)) { + continue + } + + try { + const filePath = path.join(categoryPath, file.name) + const sourcePath = path.join(type === 'agent' ? 'agents' : 'commands', 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 + } + + /** + * Scan skills directory for skill folders (recursively) + */ + private async scanSkillDirectory(): Promise { + const basePath = this.getPluginsBasePath() + const skillsPath = path.join(basePath, 'skills') + + const skills: PluginMetadata[] = [] + + try { + // Check if skills directory exists + try { + await fs.promises.access(skillsPath) + } catch { + logger.warn('Skills directory not found', { skillsPath }) + return [] + } + + // Recursively find all directories containing SKILL.md + const skillDirectories = await findAllSkillDirectories(skillsPath, basePath) + + logger.info(`Found ${skillDirectories.length} skill directories`, { skillsPath }) + + // Parse metadata for each skill directory + 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) + }) + // Continue with other skills + } + } + } catch (error) { + logger.error('Failed to scan skill directory', { skillsPath, error }) + // Return empty array on error + } + + return skills + } + + /** + * Validate source path to prevent path traversal attacks + */ + private validateSourcePath(sourcePath: string): void { + // Remove any path traversal attempts + const normalized = path.normalize(sourcePath) + + // Ensure no parent directory access + if (normalized.includes('..')) { + throw { + type: 'PATH_TRAVERSAL', + message: 'Path traversal detected', + path: sourcePath + } as PluginError + } + + // Ensure path is within plugins directory + const basePath = this.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 + } + } + + /** + * Validate workdir against agent's accessible paths + */ + private async validateWorkdir(workdir: string, agentId: string): Promise { + // Get agent from database + const agent = await AgentService.getInstance().getAgent(agentId) + + if (!agent) { + throw { + type: 'INVALID_WORKDIR', + workdir, + agentId, + message: 'Agent not found' + } as PluginError + } + + // Verify workdir is in agent's accessible_paths + if (!agent.accessible_paths?.includes(workdir)) { + throw { + type: 'INVALID_WORKDIR', + workdir, + agentId, + 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 + } + } + + /** + * 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 + } + + /** + * Validate plugin file (size, extension, frontmatter) + */ + private async validatePluginFile(filePath: string): Promise { + // Check file exists + let stats: fs.Stats + try { + stats = await fs.promises.stat(filePath) + } catch (error) { + throw { + type: 'FILE_NOT_FOUND', + path: filePath + } as PluginError + } + + // Check file size + if (stats.size > this.config.maxFileSize) { + throw { + type: 'FILE_TOO_LARGE', + size: stats.size, + max: this.config.maxFileSize + } as PluginError + } + + // Check file extension + const ext = path.extname(filePath).toLowerCase() + if (!this.ALLOWED_EXTENSIONS.includes(ext)) { + throw { + type: 'INVALID_FILE_TYPE', + extension: ext + } as PluginError + } + + // Validate frontmatter can be parsed safely + // This is handled by parsePluginMetadata which uses FAILSAFE_SCHEMA + try { + const category = path.basename(path.dirname(filePath)) + const sourcePath = path.relative(this.getPluginsBasePath(), filePath) + const type = sourcePath.startsWith('agents') ? 'agent' : 'command' + + await parsePluginMetadata(filePath, sourcePath, category, type) + } catch (error) { + throw { + type: 'INVALID_METADATA', + reason: 'Failed to parse frontmatter', + path: filePath + } as PluginError + } + } + + /** + * Calculate SHA-256 hash of file + */ + private async calculateFileHash(filePath: string): Promise { + const content = await fs.promises.readFile(filePath, 'utf8') + return crypto.createHash('sha256').update(content).digest('hex') + } + + /** + * Ensure .claude subdirectory exists for the given plugin type + */ + private async ensureClaudeDirectory(workdir: string, type: PluginType): Promise { + const claudeDir = path.join(workdir, '.claude') + + let subDir: string + if (type === 'agent') { + subDir = 'agents' + } else if (type === 'command') { + subDir = 'commands' + } else if (type === 'skill') { + subDir = 'skills' + } else { + throw new Error(`Unknown plugin type: ${type}`) + } + + const typeDir = path.join(claudeDir, subDir) + + 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 + } + } + + /** + * Transactional install operation + * Steps: + * 1. Copy to temp location + * 2. Update database + * 3. Move to final location (atomic) + * Rollback on error + */ + private async installTransaction( + agent: AgentEntity, + sourceAbsolutePath: string, + destPath: string, + metadata: PluginMetadata + ): Promise { + const tempPath = `${destPath}.tmp` + let fileCopied = false + + try { + // Step 1: Copy file to temporary location + await fs.promises.copyFile(sourceAbsolutePath, tempPath) + fileCopied = true + logger.debug('File copied to temp location', { tempPath }) + + // Step 2: Update agent configuration in database + const existingPlugins = agent.configuration?.installed_plugins || [] + const updatedPlugins = [ + ...existingPlugins, + { + sourcePath: metadata.sourcePath, + filename: metadata.filename, + type: metadata.type, + name: metadata.name, + description: metadata.description, + allowed_tools: metadata.allowed_tools, + tools: metadata.tools, + category: metadata.category, + tags: metadata.tags, + version: metadata.version, + author: metadata.author, + contentHash: metadata.contentHash, + installedAt: Date.now() + } + ] + + await AgentService.getInstance().updateAgent(agent.id, { + configuration: { + permission_mode: 'default', + max_turns: 100, + ...agent.configuration, + installed_plugins: updatedPlugins + } + }) + + logger.debug('Agent configuration updated', { agentId: agent.id }) + + // Step 3: Move temp file to final location (atomic on same filesystem) + await fs.promises.rename(tempPath, destPath) + logger.debug('File moved to final location', { destPath }) + } catch (error) { + // Rollback: delete temp file if it exists + if (fileCopied) { + try { + await fs.promises.unlink(tempPath) + logger.debug('Rolled back temp file', { tempPath }) + } catch (unlinkError) { + logger.error('Failed to rollback temp file', { + tempPath, + error: unlinkError instanceof Error ? unlinkError.message : String(unlinkError) + }) + } + } + + throw { + type: 'TRANSACTION_FAILED', + operation: 'install', + reason: error instanceof Error ? error.message : String(error) + } as PluginError + } + } + + /** + * Transactional uninstall operation + * Steps: + * 1. Update database + * 2. Delete file + * Rollback database on error + */ + private async uninstallTransaction(agent: AgentEntity, filename: string, type: 'agent' | 'command'): Promise { + const workdir = agent.accessible_paths?.[0] + if (!workdir) { + throw { + type: 'INVALID_WORKDIR', + agentId: agent.id, + workdir: '', + message: 'Agent has no accessible paths' + } as PluginError + } + + const filePath = path.join(workdir, '.claude', type === 'agent' ? 'agents' : 'commands', filename) + + // Step 1: Update database first (easier to rollback file operations) + const originalPlugins = agent.configuration?.installed_plugins || [] + const updatedPlugins = originalPlugins.filter((p) => !(p.filename === filename && p.type === type)) + + let dbUpdated = false + + try { + await AgentService.getInstance().updateAgent(agent.id, { + configuration: { + permission_mode: 'default', + max_turns: 100, + ...agent.configuration, + installed_plugins: updatedPlugins + } + }) + dbUpdated = true + logger.debug('Agent configuration updated', { agentId: agent.id }) + + // Step 2: Delete file + try { + await fs.promises.unlink(filePath) + logger.debug('Plugin file deleted', { filePath }) + } catch (error) { + const nodeError = error as NodeJS.ErrnoException + if (nodeError.code !== 'ENOENT') { + throw error // File should exist, re-throw if not ENOENT + } + logger.warn('Plugin file already deleted', { filePath }) + } + } catch (error) { + // Rollback: restore database if file deletion failed + if (dbUpdated) { + try { + await AgentService.getInstance().updateAgent(agent.id, { + configuration: { + permission_mode: 'default', + max_turns: 100, + ...agent.configuration, + installed_plugins: originalPlugins + } + }) + logger.debug('Rolled back database update', { agentId: agent.id }) + } catch (rollbackError) { + logger.error('Failed to rollback database', { + agentId: agent.id, + error: rollbackError instanceof Error ? rollbackError.message : String(rollbackError) + }) + } + } + + throw { + type: 'TRANSACTION_FAILED', + operation: 'uninstall', + reason: error instanceof Error ? error.message : String(error) + } as PluginError + } + } + + /** + * Install a skill (copy entire folder) + */ + private async installSkill( + agent: AgentEntity, + sourceAbsolutePath: string, + destPath: string, + metadata: PluginMetadata + ): Promise { + const logContext = logger.withContext('installSkill') + + // Step 1: If destination exists, remove it first (overwrite behavior) + try { + await fs.promises.access(destPath) + // Exists - remove it + await deleteDirectoryRecursive(destPath) + logContext.info('Removed existing skill folder', { destPath }) + } catch { + // Doesn't exist - nothing to remove + } + + // Step 2: Copy folder to temporary location + const tempPath = `${destPath}.tmp` + let folderCopied = false + + try { + // Copy to temp location + await copyDirectoryRecursive(sourceAbsolutePath, tempPath) + folderCopied = true + logContext.info('Skill folder copied to temp location', { tempPath }) + + // Step 3: Update agent configuration in database + const updatedPlugins = [ + ...(agent.configuration?.installed_plugins || []).filter( + (p) => !(p.filename === metadata.filename && p.type === 'skill') + ), + { + sourcePath: metadata.sourcePath, + filename: metadata.filename, // Folder name, no extension + type: metadata.type, + name: metadata.name, + description: metadata.description, + tools: metadata.tools, + category: metadata.category, + tags: metadata.tags, + version: metadata.version, + author: metadata.author, + contentHash: metadata.contentHash, + installedAt: Date.now() + } + ] + + await AgentService.getInstance().updateAgent(agent.id, { + configuration: { + permission_mode: 'default', + max_turns: 100, + ...agent.configuration, + installed_plugins: updatedPlugins + } + }) + + logContext.info('Agent configuration updated', { agentId: agent.id }) + + // Step 4: Move temp folder to final location (atomic on same filesystem) + await fs.promises.rename(tempPath, destPath) + logContext.info('Skill folder moved to final location', { destPath }) + } catch (error) { + // Rollback: delete temp folder if it exists + if (folderCopied) { + try { + await deleteDirectoryRecursive(tempPath) + logContext.info('Rolled back temp folder', { tempPath }) + } catch (unlinkError) { + logContext.error('Failed to rollback temp folder', { tempPath, error: unlinkError }) + } + } + + throw { + type: 'TRANSACTION_FAILED', + operation: 'install-skill', + reason: error instanceof Error ? error.message : String(error) + } as PluginError + } + } + + /** + * Uninstall a skill (remove entire folder) + */ + private async uninstallSkill(agent: AgentEntity, folderName: string): Promise { + const logContext = logger.withContext('uninstallSkill') + const workdir = agent.accessible_paths?.[0] + + if (!workdir) { + throw { + type: 'INVALID_WORKDIR', + agentId: agent.id, + workdir: '', + message: 'Agent has no accessible paths' + } as PluginError + } + + const skillPath = path.join(workdir, '.claude', 'skills', folderName) + + // Step 1: Update database first + const originalPlugins = agent.configuration?.installed_plugins || [] + const updatedPlugins = originalPlugins.filter((p) => !(p.filename === folderName && p.type === 'skill')) + + let dbUpdated = false + + try { + await AgentService.getInstance().updateAgent(agent.id, { + configuration: { + permission_mode: 'default', + max_turns: 100, + ...agent.configuration, + installed_plugins: updatedPlugins + } + }) + dbUpdated = true + logContext.info('Agent configuration updated', { agentId: agent.id }) + + // Step 2: Delete folder + try { + await deleteDirectoryRecursive(skillPath) + logContext.info('Skill folder deleted', { skillPath }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error // Folder should exist, re-throw if not ENOENT + } + logContext.warn('Skill folder already deleted', { skillPath }) + } + } catch (error) { + // Rollback: restore database if folder deletion failed + if (dbUpdated) { + try { + await AgentService.getInstance().updateAgent(agent.id, { + configuration: { + permission_mode: 'default', + max_turns: 100, + ...agent.configuration, + installed_plugins: originalPlugins + } + }) + logContext.info('Rolled back database update', { agentId: agent.id }) + } catch (rollbackError) { + logContext.error('Failed to rollback database', { agentId: agent.id, error: rollbackError }) + } + } + + throw { + type: 'TRANSACTION_FAILED', + operation: 'uninstall-skill', + reason: error instanceof Error ? error.message : String(error) + } as PluginError + } + } +} + +export const pluginService = PluginService.getInstance() diff --git a/src/main/services/agents/services/claudecode/index.ts b/src/main/services/agents/services/claudecode/index.ts index aa9b57860c..beb1a6a9fd 100644 --- a/src/main/services/agents/services/claudecode/index.ts +++ b/src/main/services/agents/services/claudecode/index.ts @@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events' import { createRequire } from 'node:module' -import type { McpHttpServerConfig, Options, SDKMessage } from '@anthropic-ai/claude-agent-sdk' +import type { CanUseTool, McpHttpServerConfig, Options, SDKMessage } from '@anthropic-ai/claude-agent-sdk' import { query } from '@anthropic-ai/claude-agent-sdk' import { loggerService } from '@logger' import { config as apiConfigService } from '@main/apiServer/config' @@ -12,10 +12,23 @@ import { app } from 'electron' import type { GetAgentSessionResponse } from '../..' import type { AgentServiceInterface, AgentStream, AgentStreamEvent } from '../../interfaces/AgentStreamInterface' +import { promptForToolApproval } from './tool-permissions' import { ClaudeStreamState, transformSDKMessageToStreamParts } from './transform' const require_ = createRequire(import.meta.url) const logger = loggerService.withContext('ClaudeCodeService') +const DEFAULT_AUTO_ALLOW_TOOLS = new Set(['Read', 'Glob', 'Grep']) +const shouldAutoApproveTools = process.env.CHERRY_AUTO_ALLOW_TOOLS === '1' + +type UserInputMessage = { + type: 'user' + parent_tool_use_id: string | null + session_id: string + message: { + role: 'user' + content: string + } +} class ClaudeCodeStream extends EventEmitter implements AgentStream { declare emit: (event: 'data', data: AgentStreamEvent) => boolean @@ -100,6 +113,41 @@ class ClaudeCodeService implements AgentServiceInterface { const errorChunks: string[] = [] + const sessionAllowedTools = new Set(session.allowed_tools ?? []) + const autoAllowTools = new Set([...DEFAULT_AUTO_ALLOW_TOOLS, ...sessionAllowedTools]) + const normalizeToolName = (name: string) => (name.startsWith('builtin_') ? name.slice('builtin_'.length) : name) + + const canUseTool: CanUseTool = async (toolName, input, options) => { + logger.info('Handling tool permission check', { + toolName, + suggestionCount: options.suggestions?.length ?? 0 + }) + + if (shouldAutoApproveTools) { + logger.debug('Auto-approving tool due to CHERRY_AUTO_ALLOW_TOOLS flag', { toolName }) + return { behavior: 'allow', updatedInput: input } + } + + if (options.signal.aborted) { + logger.debug('Permission request signal already aborted; denying tool', { toolName }) + return { + behavior: 'deny', + message: 'Tool request was cancelled before prompting the user' + } + } + + const normalizedToolName = normalizeToolName(toolName) + if (autoAllowTools.has(toolName) || autoAllowTools.has(normalizedToolName)) { + logger.debug('Auto-allowing tool from allowed list', { + toolName, + normalizedToolName + }) + return { behavior: 'allow', updatedInput: input } + } + + return promptForToolApproval(toolName, input, options) + } + // Build SDK options from parameters const options: Options = { abortController, @@ -122,7 +170,8 @@ class ClaudeCodeService implements AgentServiceInterface { includePartialMessages: true, permissionMode: session.configuration?.permission_mode, maxTurns: session.configuration?.max_turns, - allowedTools: session.allowed_tools + allowedTools: session.allowed_tools, + canUseTool } if (session.accessible_paths.length > 1) { @@ -161,9 +210,14 @@ class ClaudeCodeService implements AgentServiceInterface { resume: options.resume }) + const { stream: userInputStream, close: closeUserStream } = this.createUserMessageStream( + prompt, + abortController.signal + ) + // Start async processing on the next tick so listeners can subscribe first setImmediate(() => { - this.processSDKQuery(prompt, options, aiStream, errorChunks).catch((error) => { + this.processSDKQuery(userInputStream, closeUserStream, options, aiStream, errorChunks).catch((error) => { logger.error('Unhandled Claude Code stream error', { error: error instanceof Error ? { name: error.name, message: error.message } : String(error) }) @@ -177,17 +231,90 @@ class ClaudeCodeService implements AgentServiceInterface { return aiStream } - private async *userMessages(prompt: string) { - { - yield { - type: 'user' as const, - parent_tool_use_id: null, - session_id: '', - message: { - role: 'user' as const, - content: prompt + private createUserMessageStream(initialPrompt: string, abortSignal: AbortSignal) { + const queue: Array = [] + const waiters: Array<(value: UserInputMessage | null) => void> = [] + let closed = false + + const flushWaiters = (value: UserInputMessage | null) => { + const resolve = waiters.shift() + if (resolve) { + resolve(value) + return true + } + return false + } + + const enqueue = (value: UserInputMessage | null) => { + if (closed) return + if (value === null) { + closed = true + } + if (!flushWaiters(value)) { + queue.push(value) + } + } + + const close = () => { + if (closed) return + enqueue(null) + } + + const onAbort = () => { + close() + } + + if (abortSignal.aborted) { + close() + } else { + abortSignal.addEventListener('abort', onAbort, { once: true }) + } + + const iterator = (async function* () { + try { + while (true) { + let value: UserInputMessage | null + if (queue.length > 0) { + value = queue.shift() ?? null + } else if (closed) { + break + } else { + // Wait for next message or close signal + value = await new Promise((resolve) => { + waiters.push(resolve) + }) + } + + if (value === null) { + break + } + + yield value + } + } finally { + closed = true + abortSignal.removeEventListener('abort', onAbort) + while (waiters.length > 0) { + const resolve = waiters.shift() + resolve?.(null) } } + })() + + enqueue({ + type: 'user', + parent_tool_use_id: null, + session_id: '', + message: { + role: 'user', + content: initialPrompt + } + }) + + return { + stream: iterator, + enqueue, + close } } @@ -195,7 +322,8 @@ class ClaudeCodeService implements AgentServiceInterface { * Process SDK query and emit stream events */ private async processSDKQuery( - prompt: string, + promptStream: AsyncIterable, + closePromptStream: () => void, options: Options, stream: ClaudeCodeStream, errorChunks: string[] @@ -203,14 +331,10 @@ class ClaudeCodeService implements AgentServiceInterface { const jsonOutput: SDKMessage[] = [] let hasCompleted = false const startTime = Date.now() - const streamState = new ClaudeStreamState() + try { - // Process streaming responses using SDK query - for await (const message of query({ - prompt: this.userMessages(prompt), - options - })) { + for await (const message of query({ prompt: promptStream, options })) { if (hasCompleted) break jsonOutput.push(message) @@ -221,10 +345,10 @@ class ClaudeCodeService implements AgentServiceInterface { content: JSON.stringify(message.message.content) }) } else if (message.type === 'stream_event') { - logger.silly('Claude stream event', { - message, - event: JSON.stringify(message.event) - }) + // logger.silly('Claude stream event', { + // message, + // event: JSON.stringify(message.event) + // }) } else { logger.silly('Claude response', { message, @@ -232,7 +356,6 @@ class ClaudeCodeService implements AgentServiceInterface { }) } - // Transform SDKMessage to UIMessageChunks const chunks = transformSDKMessageToStreamParts(message, streamState) for (const chunk of chunks) { stream.emit('data', { @@ -242,7 +365,6 @@ class ClaudeCodeService implements AgentServiceInterface { } } - // Successfully completed hasCompleted = true const duration = Date.now() - startTime @@ -251,7 +373,6 @@ class ClaudeCodeService implements AgentServiceInterface { messageCount: jsonOutput.length }) - // Emit completion event stream.emit('data', { type: 'complete' }) @@ -260,8 +381,6 @@ class ClaudeCodeService implements AgentServiceInterface { hasCompleted = true const duration = Date.now() - startTime - - // Check if this is an abort error const errorObj = error as any const isAborted = errorObj?.name === 'AbortError' || @@ -270,7 +389,6 @@ class ClaudeCodeService implements AgentServiceInterface { if (isAborted) { logger.info('SDK query aborted by client disconnect', { duration }) - // Simply cleanup and return - don't emit error events stream.emit('data', { type: 'cancelled', error: new Error('Request aborted by client') @@ -285,11 +403,13 @@ class ClaudeCodeService implements AgentServiceInterface { error: errorObj instanceof Error ? { name: errorObj.name, message: errorObj.message } : String(errorObj), stderr: errorChunks }) - // Emit error event + stream.emit('data', { type: 'error', error: new Error(errorMessage) }) + } finally { + closePromptStream() } } } diff --git a/src/main/services/agents/services/claudecode/tool-permissions.ts b/src/main/services/agents/services/claudecode/tool-permissions.ts new file mode 100644 index 0000000000..c95f4c679e --- /dev/null +++ b/src/main/services/agents/services/claudecode/tool-permissions.ts @@ -0,0 +1,323 @@ +import { randomUUID } from 'node:crypto' + +import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk' +import { loggerService } from '@logger' +import { IpcChannel } from '@shared/IpcChannel' +import { ipcMain } from 'electron' + +import { windowService } from '../../../WindowService' +import { builtinTools } from './tools' + +const logger = loggerService.withContext('ClaudeCodeService') + +const TOOL_APPROVAL_TIMEOUT_MS = 30_000 +const MAX_PREVIEW_LENGTH = 2_000 +const shouldAutoApproveTools = process.env.CHERRY_AUTO_ALLOW_TOOLS === '1' + +type ToolPermissionBehavior = 'allow' | 'deny' + +type ToolPermissionResponsePayload = { + requestId: string + behavior: ToolPermissionBehavior + updatedInput?: unknown + message?: string + updatedPermissions?: PermissionUpdate[] +} + +type PendingPermissionRequest = { + fulfill: (update: PermissionResult) => void + timeout: NodeJS.Timeout + signal?: AbortSignal + abortListener?: () => void + originalInput: Record + toolName: string +} + +type RendererPermissionRequestPayload = { + requestId: string + toolName: string + toolId: string + description?: string + requiresPermissions: boolean + input: Record + inputPreview: string + createdAt: number + expiresAt: number + suggestions: PermissionUpdate[] +} + +type RendererPermissionResultPayload = { + requestId: string + behavior: ToolPermissionBehavior + message?: string + reason: 'response' | 'timeout' | 'aborted' | 'no-window' +} + +const pendingRequests = new Map() +let ipcHandlersInitialized = false + +const jsonReplacer = (_key: string, value: unknown) => { + if (typeof value === 'bigint') return value.toString() + if (value instanceof Map) return Object.fromEntries(value.entries()) + if (value instanceof Set) return Array.from(value.values()) + if (value instanceof Date) return value.toISOString() + if (typeof value === 'function') return undefined + if (value === undefined) return undefined + return value +} + +const sanitizeStructuredData = (value: T): T => { + try { + return JSON.parse(JSON.stringify(value, jsonReplacer)) as T + } catch (error) { + logger.warn('Failed to sanitize structured data for tool permission payload', { + error: error instanceof Error ? { name: error.name, message: error.message } : String(error) + }) + return value + } +} + +const buildInputPreview = (value: unknown): string => { + let preview: string + + try { + preview = JSON.stringify(value, null, 2) + } catch (error) { + preview = typeof value === 'string' ? value : String(value) + } + + if (preview.length > MAX_PREVIEW_LENGTH) { + preview = `${preview.slice(0, MAX_PREVIEW_LENGTH)}...` + } + + return preview +} + +const broadcastToRenderer = ( + channel: IpcChannel, + payload: RendererPermissionRequestPayload | RendererPermissionResultPayload +): boolean => { + const mainWindow = windowService.getMainWindow() + + if (!mainWindow) { + logger.warn('Unable to send agent tool permission payload – main window unavailable', { + channel, + requestId: 'requestId' in payload ? payload.requestId : undefined + }) + return false + } + + mainWindow.webContents.send(channel, payload) + + return true +} + +const finalizeRequest = ( + requestId: string, + update: PermissionResult, + reason: RendererPermissionResultPayload['reason'] +) => { + const pending = pendingRequests.get(requestId) + + if (!pending) { + logger.debug('Attempted to finalize unknown tool permission request', { requestId, reason }) + return false + } + + logger.debug('Finalizing tool permission request', { + requestId, + toolName: pending.toolName, + behavior: update.behavior, + reason + }) + + pendingRequests.delete(requestId) + clearTimeout(pending.timeout) + + if (pending.signal && pending.abortListener) { + pending.signal.removeEventListener('abort', pending.abortListener) + } + + pending.fulfill(update) + + const resultPayload: RendererPermissionResultPayload = { + requestId, + behavior: update.behavior, + message: update.behavior === 'deny' ? update.message : undefined, + reason + } + + const dispatched = broadcastToRenderer(IpcChannel.AgentToolPermission_Result, resultPayload) + + logger.debug('Sent tool permission result to renderer', { + requestId, + dispatched + }) + + return true +} + +const ensureIpcHandlersRegistered = () => { + if (ipcHandlersInitialized) return + + ipcHandlersInitialized = true + + ipcMain.handle(IpcChannel.AgentToolPermission_Response, async (_event, payload: ToolPermissionResponsePayload) => { + logger.debug('main received AgentToolPermission_Response', payload) + const { requestId, behavior, updatedInput, message } = payload + const pending = pendingRequests.get(requestId) + + if (!pending) { + logger.warn('Received renderer tool permission response for unknown request', { requestId }) + return { success: false, error: 'unknown-request' } + } + + logger.debug('Received renderer response for tool permission', { + requestId, + toolName: pending.toolName, + behavior, + hasUpdatedPermissions: Array.isArray(payload.updatedPermissions) && payload.updatedPermissions.length > 0 + }) + + const maybeUpdatedInput = + updatedInput && typeof updatedInput === 'object' && !Array.isArray(updatedInput) + ? (updatedInput as Record) + : pending.originalInput + + const sanitizedUpdatedPermissions = Array.isArray(payload.updatedPermissions) + ? payload.updatedPermissions.map((perm) => sanitizeStructuredData(perm)) + : undefined + + const finalUpdate: PermissionResult = + behavior === 'allow' + ? { + behavior: 'allow', + updatedInput: sanitizeStructuredData(maybeUpdatedInput), + updatedPermissions: sanitizedUpdatedPermissions + } + : { + behavior: 'deny', + message: message ?? 'User denied permission for this tool' + } + + finalizeRequest(requestId, finalUpdate, 'response') + + return { success: true } + }) +} + +export async function promptForToolApproval( + toolName: string, + input: Record, + options?: { signal: AbortSignal; suggestions?: PermissionUpdate[] } +): Promise { + if (shouldAutoApproveTools) { + logger.debug('promptForToolApproval auto-approving tool for test', { + toolName + }) + + return { behavior: 'allow', updatedInput: input } + } + + ensureIpcHandlersRegistered() + + if (options?.signal?.aborted) { + logger.info('Skipping tool approval prompt because request signal is already aborted', { toolName }) + return { behavior: 'deny', message: 'Tool request was cancelled before prompting the user' } + } + + const mainWindow = windowService.getMainWindow() + + if (!mainWindow) { + logger.warn('Denying tool usage because no renderer window is available to obtain approval', { toolName }) + return { behavior: 'deny', message: 'Unable to request approval – renderer not ready' } + } + + const toolMetadata = builtinTools.find((tool) => tool.name === toolName || tool.id === toolName) + const sanitizedInput = sanitizeStructuredData(input) + const inputPreview = buildInputPreview(sanitizedInput) + const sanitizedSuggestions = (options?.suggestions ?? []).map((suggestion) => sanitizeStructuredData(suggestion)) + + const requestId = randomUUID() + const createdAt = Date.now() + const expiresAt = createdAt + TOOL_APPROVAL_TIMEOUT_MS + + logger.info('Requesting user approval for tool usage', { + requestId, + toolName, + description: toolMetadata?.description + }) + + const requestPayload: RendererPermissionRequestPayload = { + requestId, + toolName, + toolId: toolMetadata?.id ?? toolName, + description: toolMetadata?.description, + requiresPermissions: toolMetadata?.requirePermissions ?? false, + input: sanitizedInput, + inputPreview, + createdAt, + expiresAt, + suggestions: sanitizedSuggestions + } + + const defaultDenyUpdate: PermissionResult = { behavior: 'deny', message: 'Tool request aborted before user decision' } + + logger.debug('Registering tool permission request', { + requestId, + toolName, + requiresPermissions: requestPayload.requiresPermissions, + timeoutMs: TOOL_APPROVAL_TIMEOUT_MS, + suggestionCount: sanitizedSuggestions.length + }) + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + logger.info('User tool permission request timed out', { requestId, toolName }) + finalizeRequest(requestId, { behavior: 'deny', message: 'Timed out waiting for approval' }, 'timeout') + }, TOOL_APPROVAL_TIMEOUT_MS) + + const pending: PendingPermissionRequest = { + fulfill: resolve, + timeout, + originalInput: sanitizedInput, + toolName, + signal: options?.signal + } + + if (options?.signal) { + const abortListener = () => { + logger.info('Tool permission request aborted before user responded', { requestId, toolName }) + finalizeRequest(requestId, defaultDenyUpdate, 'aborted') + } + + pending.abortListener = abortListener + options.signal.addEventListener('abort', abortListener, { once: true }) + } + + pendingRequests.set(requestId, pending) + + logger.debug('Pending tool permission request count', { + count: pendingRequests.size + }) + + const sent = broadcastToRenderer(IpcChannel.AgentToolPermission_Request, requestPayload) + + logger.debug('Broadcasted tool permission request to renderer', { + requestId, + toolName, + sent + }) + + if (!sent) { + finalizeRequest( + requestId, + { + behavior: 'deny', + message: 'Unable to request approval because the renderer window is unavailable' + }, + 'no-window' + ) + } + }) +} diff --git a/src/main/utils/fileOperations.ts b/src/main/utils/fileOperations.ts new file mode 100644 index 0000000000..6352126e2a --- /dev/null +++ b/src/main/utils/fileOperations.ts @@ -0,0 +1,223 @@ +import * as fs from 'node:fs' +import * as path from 'node:path' + +import { loggerService } from '@logger' + +import { isPathInside } from './file' + +const logger = loggerService.withContext('Utils:FileOperations') + +const MAX_RECURSION_DEPTH = 1000 + +/** + * Recursively copy a directory and all its contents + * @param source - Source directory path (must be absolute) + * @param destination - Destination directory path (must be absolute) + * @param options - Copy options + * @param depth - Current recursion depth (internal use) + * @throws If copy operation fails or paths are invalid + */ +export async function copyDirectoryRecursive( + source: string, + destination: string, + options?: { allowedBasePath?: string }, + depth = 0 +): Promise { + // Input validation + if (!source || !destination) { + throw new TypeError('Source and destination paths are required') + } + + if (!path.isAbsolute(source) || !path.isAbsolute(destination)) { + throw new Error('Source and destination paths must be absolute') + } + + // Depth limit to prevent stack overflow + if (depth > MAX_RECURSION_DEPTH) { + throw new Error(`Maximum recursion depth exceeded: ${MAX_RECURSION_DEPTH}`) + } + + // Path validation - ensure operations stay within allowed boundaries + if (options?.allowedBasePath) { + if (!isPathInside(source, options.allowedBasePath)) { + throw new Error(`Source path is outside allowed directory: ${source}`) + } + if (!isPathInside(destination, options.allowedBasePath)) { + throw new Error(`Destination path is outside allowed directory: ${destination}`) + } + } + + try { + // Verify source exists and is a directory + const sourceStats = await fs.promises.lstat(source) + if (!sourceStats.isDirectory()) { + throw new Error(`Source is not a directory: ${source}`) + } + + // Create destination directory + await fs.promises.mkdir(destination, { recursive: true }) + logger.debug('Created destination directory', { destination }) + + // Read source directory + const entries = await fs.promises.readdir(source, { withFileTypes: true }) + + // Copy each entry + for (const entry of entries) { + const sourcePath = path.join(source, entry.name) + const destPath = path.join(destination, entry.name) + + // Use lstat to detect symlinks and prevent following them + const entryStats = await fs.promises.lstat(sourcePath) + + if (entryStats.isSymbolicLink()) { + logger.warn('Skipping symlink for security', { path: sourcePath }) + continue + } + + if (entryStats.isDirectory()) { + // Recursively copy subdirectory + await copyDirectoryRecursive(sourcePath, destPath, options, depth + 1) + } else if (entryStats.isFile()) { + // Copy file with error handling for race conditions + try { + await fs.promises.copyFile(sourcePath, destPath) + // Preserve file permissions + await fs.promises.chmod(destPath, entryStats.mode) + logger.debug('Copied file', { from: sourcePath, to: destPath }) + } catch (error) { + // Handle race condition where file was deleted during copy + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + logger.warn('File disappeared during copy', { sourcePath }) + continue + } + throw error + } + } else { + // Skip special files (pipes, sockets, devices, etc.) + logger.debug('Skipping special file', { path: sourcePath }) + } + } + + logger.info('Directory copied successfully', { from: source, to: destination, depth }) + } catch (error) { + logger.error('Failed to copy directory', { source, destination, depth, error }) + throw error + } +} + +/** + * Recursively delete a directory and all its contents + * @param dirPath - Directory path to delete (must be absolute) + * @param options - Delete options + * @throws If deletion fails or path is invalid + */ +export async function deleteDirectoryRecursive(dirPath: string, options?: { allowedBasePath?: string }): Promise { + // Input validation + if (!dirPath) { + throw new TypeError('Directory path is required') + } + + if (!path.isAbsolute(dirPath)) { + throw new Error('Directory path must be absolute') + } + + // Path validation - ensure operations stay within allowed boundaries + if (options?.allowedBasePath) { + if (!isPathInside(dirPath, options.allowedBasePath)) { + throw new Error(`Path is outside allowed directory: ${dirPath}`) + } + } + + try { + // Verify path exists before attempting deletion + try { + const stats = await fs.promises.lstat(dirPath) + if (!stats.isDirectory()) { + throw new Error(`Path is not a directory: ${dirPath}`) + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + logger.warn('Directory already deleted', { dirPath }) + return + } + throw error + } + + // Node.js 14.14+ has fs.rm with recursive option + await fs.promises.rm(dirPath, { recursive: true, force: true }) + logger.info('Directory deleted successfully', { dirPath }) + } catch (error) { + logger.error('Failed to delete directory', { dirPath, error }) + throw error + } +} + +/** + * Get total size of a directory (in bytes) + * @param dirPath - Directory path (must be absolute) + * @param options - Size calculation options + * @param depth - Current recursion depth (internal use) + * @returns Total size in bytes + * @throws If size calculation fails or path is invalid + */ +export async function getDirectorySize( + dirPath: string, + options?: { allowedBasePath?: string }, + depth = 0 +): Promise { + // Input validation + if (!dirPath) { + throw new TypeError('Directory path is required') + } + + if (!path.isAbsolute(dirPath)) { + throw new Error('Directory path must be absolute') + } + + // Depth limit to prevent stack overflow + if (depth > MAX_RECURSION_DEPTH) { + throw new Error(`Maximum recursion depth exceeded: ${MAX_RECURSION_DEPTH}`) + } + + // Path validation - ensure operations stay within allowed boundaries + if (options?.allowedBasePath) { + if (!isPathInside(dirPath, options.allowedBasePath)) { + throw new Error(`Path is outside allowed directory: ${dirPath}`) + } + } + + let totalSize = 0 + + try { + const entries = await fs.promises.readdir(dirPath, { withFileTypes: true }) + + for (const entry of entries) { + const entryPath = path.join(dirPath, entry.name) + + // Use lstat to detect symlinks and prevent following them + const entryStats = await fs.promises.lstat(entryPath) + + if (entryStats.isSymbolicLink()) { + logger.debug('Skipping symlink in size calculation', { path: entryPath }) + continue + } + + if (entryStats.isDirectory()) { + // Recursively get size of subdirectory + totalSize += await getDirectorySize(entryPath, options, depth + 1) + } else if (entryStats.isFile()) { + // Get file size from lstat (already have it) + totalSize += entryStats.size + } else { + // Skip special files + logger.debug('Skipping special file in size calculation', { path: entryPath }) + } + } + + logger.debug('Calculated directory size', { dirPath, size: totalSize, depth }) + return totalSize + } catch (error) { + logger.error('Failed to calculate directory size', { dirPath, depth, error }) + throw error + } +} diff --git a/src/main/utils/markdownParser.ts b/src/main/utils/markdownParser.ts new file mode 100644 index 0000000000..9c3d7c9540 --- /dev/null +++ b/src/main/utils/markdownParser.ts @@ -0,0 +1,309 @@ +import { loggerService } from '@logger' +import type { PluginError, PluginMetadata } from '@types' +import * as crypto from 'crypto' +import * as fs from 'fs' +import matter from 'gray-matter' +import * as yaml from 'js-yaml' +import * as path from 'path' + +import { getDirectorySize } from './fileOperations' + +const logger = loggerService.withContext('Utils:MarkdownParser') + +/** + * Parse plugin metadata from a markdown file with frontmatter + * @param filePath Absolute path to the markdown file + * @param sourcePath Relative source path from plugins directory + * @param category Category name derived from parent folder + * @param type Plugin type (agent or command) + * @returns PluginMetadata object with parsed frontmatter and file info + */ +export async function parsePluginMetadata( + filePath: string, + sourcePath: string, + category: string, + type: 'agent' | 'command' +): Promise { + const content = await fs.promises.readFile(filePath, 'utf8') + const stats = await fs.promises.stat(filePath) + + // Parse frontmatter safely with FAILSAFE_SCHEMA to prevent deserialization attacks + const { data } = matter(content, { + engines: { + yaml: (s) => yaml.load(s, { schema: yaml.FAILSAFE_SCHEMA }) as object + } + }) + + // Calculate content hash for integrity checking + const contentHash = crypto.createHash('sha256').update(content).digest('hex') + + // Extract filename + const filename = path.basename(filePath) + + // Parse allowed_tools - handle both array and comma-separated string + let allowedTools: string[] | undefined + if (data['allowed-tools'] || data.allowed_tools) { + const toolsData = data['allowed-tools'] || data.allowed_tools + if (Array.isArray(toolsData)) { + allowedTools = toolsData + } else if (typeof toolsData === 'string') { + allowedTools = toolsData + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + } + } + + // Parse tools - similar handling + let tools: string[] | undefined + if (data.tools) { + if (Array.isArray(data.tools)) { + tools = data.tools + } else if (typeof data.tools === 'string') { + tools = data.tools + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + } + } + + // Parse tags + let tags: string[] | undefined + if (data.tags) { + if (Array.isArray(data.tags)) { + tags = data.tags + } else if (typeof data.tags === 'string') { + tags = data.tags + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + } + } + + return { + sourcePath, + filename, + name: data.name || filename.replace(/\.md$/, ''), + description: data.description, + allowed_tools: allowedTools, + tools, + category, + type, + tags, + version: data.version, + author: data.author, + size: stats.size, + contentHash + } +} + +/** + * Recursively find all directories containing SKILL.md + * + * @param dirPath - Directory to search in + * @param basePath - Base path for calculating relative source paths + * @param maxDepth - Maximum depth to search (default: 10 to prevent infinite loops) + * @param currentDepth - Current search depth (used internally) + * @returns Array of objects with absolute folder path and relative source path + */ +export async function findAllSkillDirectories( + dirPath: string, + basePath: string, + maxDepth = 10, + currentDepth = 0 +): Promise> { + const results: Array<{ folderPath: string; sourcePath: string }> = [] + + // Prevent excessive recursion + if (currentDepth > maxDepth) { + return results + } + + // Check if current directory contains SKILL.md + const skillMdPath = path.join(dirPath, 'SKILL.md') + + try { + await fs.promises.stat(skillMdPath) + // Found SKILL.md in this directory + const relativePath = path.relative(basePath, dirPath) + results.push({ + folderPath: dirPath, + sourcePath: relativePath + }) + return results + } catch { + // SKILL.md not in current directory + } + + // Only search subdirectories if current directory doesn't have SKILL.md + try { + const entries = await fs.promises.readdir(dirPath, { withFileTypes: true }) + + for (const entry of entries) { + if (entry.isDirectory()) { + const subDirPath = path.join(dirPath, entry.name) + const subResults = await findAllSkillDirectories(subDirPath, basePath, maxDepth, currentDepth + 1) + results.push(...subResults) + } + } + } catch (error: any) { + // Ignore errors when reading subdirectories (e.g., permission denied) + logger.debug('Failed to read subdirectory during skill search', { + dirPath, + error: error.message + }) + } + + return results +} + +/** + * Parse metadata from SKILL.md within a skill folder + * + * @param skillFolderPath - Absolute path to skill folder (must be absolute and contain SKILL.md) + * @param sourcePath - Relative path from plugins base (e.g., "skills/my-skill") + * @param category - Category name (typically "skills" for flat structure) + * @returns PluginMetadata with folder name as filename (no extension) + * @throws PluginError if SKILL.md not found or parsing fails + */ +export async function parseSkillMetadata( + skillFolderPath: string, + sourcePath: string, + category: string +): Promise { + // Input validation + if (!skillFolderPath || !path.isAbsolute(skillFolderPath)) { + throw { + type: 'INVALID_METADATA', + reason: 'Skill folder path must be absolute', + path: skillFolderPath + } as PluginError + } + + // Look for SKILL.md directly in this folder (no recursion) + const skillMdPath = path.join(skillFolderPath, 'SKILL.md') + + // Check if SKILL.md exists + try { + await fs.promises.stat(skillMdPath) + } catch (error: any) { + if (error.code === 'ENOENT') { + logger.error('SKILL.md not found in skill folder', { skillMdPath }) + throw { + type: 'FILE_NOT_FOUND', + path: skillMdPath, + message: 'SKILL.md not found in skill folder' + } as PluginError + } + throw error + } + + // Read SKILL.md content + let content: string + try { + content = await fs.promises.readFile(skillMdPath, 'utf8') + } catch (error: any) { + logger.error('Failed to read SKILL.md', { skillMdPath, error }) + throw { + type: 'READ_FAILED', + path: skillMdPath, + reason: error.message || 'Unknown error' + } as PluginError + } + + // Parse frontmatter safely with FAILSAFE_SCHEMA to prevent deserialization attacks + let data: any + try { + const parsed = matter(content, { + engines: { + yaml: (s) => yaml.load(s, { schema: yaml.FAILSAFE_SCHEMA }) as object + } + }) + data = parsed.data + } catch (error: any) { + logger.error('Failed to parse SKILL.md frontmatter', { skillMdPath, error }) + throw { + type: 'INVALID_METADATA', + reason: `Failed to parse frontmatter: ${error.message}`, + path: skillMdPath + } as PluginError + } + + // Calculate hash of SKILL.md only (not entire folder) + // Note: This means changes to other files in the skill won't trigger cache invalidation + // This is intentional - only SKILL.md metadata changes should trigger updates + const contentHash = crypto.createHash('sha256').update(content).digest('hex') + + // Get folder name as identifier (NO EXTENSION) + const folderName = path.basename(skillFolderPath) + + // Get total folder size + let folderSize: number + try { + folderSize = await getDirectorySize(skillFolderPath) + } catch (error: any) { + logger.error('Failed to calculate skill folder size', { skillFolderPath, error }) + // Use 0 as fallback instead of failing completely + folderSize = 0 + } + + // Parse tools (skills use 'tools', not 'allowed_tools') + let tools: string[] | undefined + if (data.tools) { + if (Array.isArray(data.tools)) { + // Validate all elements are strings + tools = data.tools.filter((t) => typeof t === 'string') + } else if (typeof data.tools === 'string') { + tools = data.tools + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + } + } + + // Parse tags + let tags: string[] | undefined + if (data.tags) { + if (Array.isArray(data.tags)) { + // Validate all elements are strings + tags = data.tags.filter((t) => typeof t === 'string') + } else if (typeof data.tags === 'string') { + tags = data.tags + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + } + } + + // Validate and sanitize name + const name = typeof data.name === 'string' && data.name.trim() ? data.name.trim() : folderName + + // Validate and sanitize description + const description = + typeof data.description === 'string' && data.description.trim() ? data.description.trim() : undefined + + // Validate version and author + const version = typeof data.version === 'string' ? data.version : undefined + const author = typeof data.author === 'string' ? data.author : undefined + + logger.debug('Successfully parsed skill metadata', { + skillFolderPath, + folderName, + size: folderSize + }) + + return { + sourcePath, // e.g., "skills/my-skill" + filename: folderName, // e.g., "my-skill" (folder name, NO .md extension) + name, + description, + tools, + category, // "skills" for flat structure + type: 'skill', + tags, + version, + author, + size: folderSize, + contentHash // Hash of SKILL.md content only + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 1fa5ee0339..3c19f8c96a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,3 +1,4 @@ +import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk' import { electronAPI } from '@electron-toolkit/preload' import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' import type { SpanContext } from '@opentelemetry/api' @@ -42,6 +43,16 @@ import type { OpenDialogOptions } from 'electron' import { contextBridge, ipcRenderer, shell, webUtils } from 'electron' import type { CreateDirectoryOptions } from 'webdav' +import type { + InstalledPlugin, + InstallPluginOptions, + ListAvailablePluginsResult, + PluginMetadata, + PluginResult, + UninstallPluginOptions, + WritePluginContentOptions +} from '../renderer/src/types/plugin' + export function tracedInvoke(channel: string, spanContext: SpanContext | undefined, ...args: any[]) { if (spanContext) { const data = { type: 'trace', context: spanContext } @@ -426,6 +437,15 @@ const api = { minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize), pinActionWindow: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowPin, isPinned) }, + agentTools: { + respondToPermission: (payload: { + requestId: string + behavior: 'allow' | 'deny' + updatedInput?: Record + message?: string + updatedPermissions?: PermissionUpdate[] + }) => ipcRenderer.invoke(IpcChannel.AgentToolPermission_Response, payload) + }, quoteToMainWindow: (text: string) => ipcRenderer.invoke(IpcChannel.App_QuoteToMain, text), // setDisableHardwareAcceleration: (isDisable: boolean) => // ipcRenderer.invoke(IpcChannel.App_SetDisableHardwareAcceleration, isDisable), @@ -548,6 +568,21 @@ const api = { start: (): Promise => ipcRenderer.invoke(IpcChannel.ApiServer_Start), restart: (): Promise => ipcRenderer.invoke(IpcChannel.ApiServer_Restart), stop: (): Promise => ipcRenderer.invoke(IpcChannel.ApiServer_Stop) + }, + claudeCodePlugin: { + listAvailable: (): Promise> => + ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_ListAvailable), + install: (options: InstallPluginOptions): Promise> => + ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_Install, options), + uninstall: (options: UninstallPluginOptions): Promise> => + ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_Uninstall, options), + listInstalled: (agentId: string): Promise> => + ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_ListInstalled, agentId), + invalidateCache: (): Promise> => ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_InvalidateCache), + readContent: (sourcePath: string): Promise> => + ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_ReadContent, sourcePath), + writeContent: (options: WritePluginContentOptions): Promise> => + ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_WriteContent, options) } } diff --git a/src/renderer/src/aiCore/index_new.ts b/src/renderer/src/aiCore/index_new.ts index 4872f02fbc..97c43e9319 100644 --- a/src/renderer/src/aiCore/index_new.ts +++ b/src/renderer/src/aiCore/index_new.ts @@ -98,7 +98,8 @@ export default class ModernAiProvider { // 提前构建中间件 const middlewares = buildAiSdkMiddlewares({ ...config, - provider: this.actualProvider + provider: this.actualProvider, + assistant: config.assistant }) logger.debug('Built middlewares in completions', { middlewareCount: middlewares.length, diff --git a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts index e792e72bb5..3f14917cdd 100644 --- a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts +++ b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts @@ -1,13 +1,18 @@ import type { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins' import { loggerService } from '@logger' -import { type MCPTool, type Message, type Model, type Provider } from '@renderer/types' +import { isSupportedThinkingTokenQwenModel } from '@renderer/config/models' +import { isSupportEnableThinkingProvider } from '@renderer/config/providers' +import type { MCPTool } from '@renderer/types' +import { type Assistant, type Message, type Model, type Provider } from '@renderer/types' import type { Chunk } from '@renderer/types/chunk' import type { LanguageModelMiddleware } from 'ai' import { extractReasoningMiddleware, simulateStreamingMiddleware } from 'ai' +import { isEmpty } from 'lodash' import { isOpenRouterGeminiGenerateImageModel } from '../utils/image' import { noThinkMiddleware } from './noThinkMiddleware' import { openrouterGenerateImageMiddleware } from './openrouterGenerateImageMiddleware' +import { qwenThinkingMiddleware } from './qwenThinkingMiddleware' import { toolChoiceMiddleware } from './toolChoiceMiddleware' const logger = loggerService.withContext('AiSdkMiddlewareBuilder') @@ -20,6 +25,7 @@ export interface AiSdkMiddlewareConfig { onChunk?: (chunk: Chunk) => void model?: Model provider?: Provider + assistant?: Assistant enableReasoning: boolean // 是否开启提示词工具调用 isPromptToolUse: boolean @@ -128,7 +134,7 @@ export function buildAiSdkMiddlewares(config: AiSdkMiddlewareConfig): LanguageMo const builder = new AiSdkMiddlewareBuilder() // 0. 知识库强制调用中间件(必须在最前面,确保第一轮强制调用知识库) - if (config.knowledgeRecognition === 'off') { + if (!isEmpty(config.assistant?.knowledge_bases?.map((base) => base.id)) && config.knowledgeRecognition !== 'on') { builder.add({ name: 'force-knowledge-first', middleware: toolChoiceMiddleware('builtin_knowledge_search') @@ -219,6 +225,21 @@ function addProviderSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config: function addModelSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config: AiSdkMiddlewareConfig): void { if (!config.model || !config.provider) return + // Qwen models on providers that don't support enable_thinking parameter (like Ollama, LM Studio, NVIDIA) + // Use /think or /no_think suffix to control thinking mode + if ( + config.provider && + isSupportedThinkingTokenQwenModel(config.model) && + !isSupportEnableThinkingProvider(config.provider) + ) { + const enableThinking = config.assistant?.settings?.reasoning_effort !== undefined + builder.add({ + name: 'qwen-thinking-control', + middleware: qwenThinkingMiddleware(enableThinking) + }) + logger.debug(`Added Qwen thinking middleware with thinking ${enableThinking ? 'enabled' : 'disabled'}`) + } + // 可以根据模型ID或特性添加特定中间件 // 例如:图像生成模型、多模态模型等 if (isOpenRouterGeminiGenerateImageModel(config.model, config.provider)) { diff --git a/src/renderer/src/aiCore/middleware/qwenThinkingMiddleware.ts b/src/renderer/src/aiCore/middleware/qwenThinkingMiddleware.ts new file mode 100644 index 0000000000..931831a1c6 --- /dev/null +++ b/src/renderer/src/aiCore/middleware/qwenThinkingMiddleware.ts @@ -0,0 +1,39 @@ +import type { LanguageModelMiddleware } from 'ai' + +/** + * Qwen Thinking Middleware + * Controls thinking mode for Qwen models on providers that don't support enable_thinking parameter (like Ollama) + * Appends '/think' or '/no_think' suffix to user messages based on reasoning_effort setting + * @param enableThinking - Whether thinking mode is enabled (based on reasoning_effort !== undefined) + * @returns LanguageModelMiddleware + */ +export function qwenThinkingMiddleware(enableThinking: boolean): LanguageModelMiddleware { + const suffix = enableThinking ? ' /think' : ' /no_think' + + return { + middlewareVersion: 'v2', + + transformParams: async ({ params }) => { + const transformedParams = { ...params } + // Process messages in prompt + if (transformedParams.prompt && Array.isArray(transformedParams.prompt)) { + transformedParams.prompt = transformedParams.prompt.map((message) => { + // Only process user messages + if (message.role === 'user') { + // Process content array + if (Array.isArray(message.content)) { + for (const part of message.content) { + if (part.type === 'text' && !part.text.endsWith('/think') && !part.text.endsWith('/no_think')) { + part.text += suffix + } + } + } + } + return message + }) + } + + return transformedParams + } + } +} diff --git a/src/renderer/src/components/LocalBackupManager.tsx b/src/renderer/src/components/LocalBackupManager.tsx index 3018a331de..233e90f576 100644 --- a/src/renderer/src/components/LocalBackupManager.tsx +++ b/src/renderer/src/components/LocalBackupManager.tsx @@ -2,7 +2,7 @@ import { DeleteOutlined, ExclamationCircleOutlined, ReloadOutlined } from '@ant- import { Button, Flex, Tooltip } from '@cherrystudio/ui' import { restoreFromLocal } from '@renderer/services/BackupService' import { formatFileSize } from '@renderer/utils' -import { Modal, Table } from 'antd' +import { Modal, Space, Table } from 'antd' import dayjs from 'dayjs' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -221,6 +221,26 @@ export function LocalBackupManager({ visible, onClose, localBackupDir, restoreMe } } + const footerContent = ( + + + + + + ) + return ( - - {t('settings.data.local.backup.manager.refresh')} - , - , - - ]}> + footer={footerContent}> should match snapshot 1`] = ` + + {hasSuggestions ? ( + + + + + ) : ( + + )} + + + + + + + {showDetails && ( +
+
+ {t('agent.toolPermission.confirmation')} +
+ +
+

+ {t('agent.toolPermission.inputPreview')} +

+ +
{request.inputPreview}
+
+
+ + {request.requiresPermissions && ( +
+ {t('agent.toolPermission.requiresElevatedPermissions')} +
+ )} + + {request.suggestions.length > 0 && ( +
+ {request.suggestions.length === 1 + ? t('agent.toolPermission.suggestion.permissionUpdateSingle') + : t('agent.toolPermission.suggestion.permissionUpdateMultiple')} +
+ )} +
+ )} + + {isExpired && !isSubmitting && ( +
{t('agent.toolPermission.permissionExpired')}
+ )} + + + ) +} + +export default ToolPermissionRequestCard diff --git a/src/renderer/src/pages/home/Tabs/components/Sessions.tsx b/src/renderer/src/pages/home/Tabs/components/Sessions.tsx index 4ccf44f0cf..09545f8584 100644 --- a/src/renderer/src/pages/home/Tabs/components/Sessions.tsx +++ b/src/renderer/src/pages/home/Tabs/components/Sessions.tsx @@ -1,6 +1,6 @@ import { Alert, Spinner } from '@heroui/react' import { DynamicVirtualList } from '@renderer/components/VirtualList' -import { useAgent } from '@renderer/hooks/agents/useAgent' +import { useCreateDefaultSession } from '@renderer/hooks/agents/useCreateDefaultSession' import { useSessions } from '@renderer/hooks/agents/useSessions' import { useRuntime } from '@renderer/hooks/useRuntime' import { useAppDispatch } from '@renderer/store' @@ -10,7 +10,6 @@ import { setActiveTopicOrSessionAction, setSessionWaitingAction } from '@renderer/store/runtime' -import type { CreateSessionForm } from '@renderer/types' import { buildAgentSessionTopicId } from '@renderer/utils/agentSession' import { motion } from 'framer-motion' import { memo, useCallback, useEffect } from 'react' @@ -27,11 +26,11 @@ interface SessionsProps { const Sessions: React.FC = ({ agentId }) => { const { t } = useTranslation() - const { agent } = useAgent(agentId) - const { sessions, isLoading, error, deleteSession, createSession } = useSessions(agentId) + const { sessions, isLoading, error, deleteSession } = useSessions(agentId) const { chat } = useRuntime() const { activeSessionIdMap } = chat const dispatch = useAppDispatch() + const { createDefaultSession, creatingSession } = useCreateDefaultSession(agentId) const setActiveSessionId = useCallback( (agentId: string, sessionId: string | null) => { @@ -41,19 +40,6 @@ const Sessions: React.FC = ({ agentId }) => { [dispatch] ) - const handleCreateSession = useCallback(async () => { - if (!agent) return - const session = { - ...agent, - id: undefined, - name: t('common.unnamed') - } satisfies CreateSessionForm - const created = await createSession(session) - if (created) { - dispatch(setActiveSessionIdAction({ agentId, sessionId: created.id })) - } - }, [agent, agentId, createSession, dispatch, t]) - const handleDeleteSession = useCallback( async (id: string) => { if (sessions.length === 1) { @@ -110,7 +96,7 @@ const Sessions: React.FC = ({ agentId }) => { return (
- + {t('agent.session.add.title')} {/* h-9 */} diff --git a/src/renderer/src/pages/settings/AgentSettings/AgentSettingsPopup.tsx b/src/renderer/src/pages/settings/AgentSettings/AgentSettingsPopup.tsx index 516f0248ec..589e7f48cd 100644 --- a/src/renderer/src/pages/settings/AgentSettings/AgentSettingsPopup.tsx +++ b/src/renderer/src/pages/settings/AgentSettings/AgentSettingsPopup.tsx @@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next' import AdvancedSettings from './AdvancedSettings' import EssentialSettings from './EssentialSettings' +import PluginSettings from './PluginSettings' import PromptSettings from './PromptSettings' import { AgentLabel, LeftMenu, Settings, StyledMenu, StyledModal } from './shared' import ToolingSettings from './ToolingSettings' @@ -20,7 +21,7 @@ interface AgentSettingPopupParams extends AgentSettingPopupShowParams { resolve: () => void } -type AgentSettingPopupTab = 'essential' | 'prompt' | 'tooling' | 'advanced' | 'session-mcps' +type AgentSettingPopupTab = 'essential' | 'prompt' | 'tooling' | 'advanced' | 'plugins' | 'session-mcps' const AgentSettingPopupContainer: React.FC = ({ tab, agentId, resolve }) => { const [open, setOpen] = useState(true) @@ -56,6 +57,10 @@ const AgentSettingPopupContainer: React.FC = ({ tab, ag key: 'tooling', label: t('agent.settings.tooling.tab', 'Tooling & permissions') }, + { + key: 'plugins', + label: t('agent.settings.plugins.tab', 'Plugins') + }, { key: 'advanced', label: t('agent.settings.advance.title', 'Advanced Settings') @@ -75,6 +80,9 @@ const AgentSettingPopupContainer: React.FC = ({ tab, ag
) } + if (!agent) { + return null + } return (
@@ -90,6 +98,7 @@ const AgentSettingPopupContainer: React.FC = ({ tab, ag {menu === 'essential' && } {menu === 'prompt' && } {menu === 'tooling' && } + {menu === 'plugins' && } {menu === 'advanced' && }
diff --git a/src/renderer/src/pages/settings/AgentSettings/AvatarSetting.tsx b/src/renderer/src/pages/settings/AgentSettings/AvatarSetting.tsx index 46f4f1d355..2c4c0fdb4e 100644 --- a/src/renderer/src/pages/settings/AgentSettings/AvatarSetting.tsx +++ b/src/renderer/src/pages/settings/AgentSettings/AvatarSetting.tsx @@ -1,6 +1,6 @@ import { EmojiAvatarWithPicker } from '@renderer/components/Avatar/EmojiAvatarWithPicker' import type { AgentEntity, UpdateAgentForm } from '@renderer/types' -import { isAgentType } from '@renderer/types' +import { AgentConfigurationSchema, isAgentType } from '@renderer/types' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -20,13 +20,11 @@ export const AvatarSetting: React.FC = ({ agent, update }) const updateAvatar = useCallback( (avatar: string) => { + const parsedConfiguration = AgentConfigurationSchema.parse(agent.configuration ?? {}) const payload = { id: agent.id, - // hard-encoded default values. better to implement incremental update for configuration configuration: { - ...agent.configuration, - permission_mode: agent.configuration?.permission_mode ?? 'default', - max_turns: agent.configuration?.max_turns ?? 100, + ...parsedConfiguration, avatar } } satisfies UpdateAgentForm diff --git a/src/renderer/src/pages/settings/AgentSettings/PluginSettings.tsx b/src/renderer/src/pages/settings/AgentSettings/PluginSettings.tsx new file mode 100644 index 0000000000..9338319b9f --- /dev/null +++ b/src/renderer/src/pages/settings/AgentSettings/PluginSettings.tsx @@ -0,0 +1,115 @@ +import { Card, CardBody, Tab, Tabs } from '@heroui/react' +import { useAvailablePlugins, useInstalledPlugins, usePluginActions } from '@renderer/hooks/usePlugins' +import type { GetAgentResponse, GetAgentSessionResponse, UpdateAgentBaseForm } from '@renderer/types/agent' +import type { FC } from 'react' +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' + +import { InstalledPluginsList } from './components/InstalledPluginsList' +import { PluginBrowser } from './components/PluginBrowser' +import { SettingsContainer } from './shared' + +interface PluginSettingsProps { + agentBase: GetAgentResponse | GetAgentSessionResponse + update: (partial: UpdateAgentBaseForm) => Promise +} + +const PluginSettings: FC = ({ agentBase }) => { + const { t } = useTranslation() + + // Fetch available plugins + const { agents, commands, skills, loading: loadingAvailable, error: errorAvailable } = useAvailablePlugins() + + // Fetch installed plugins + const { plugins, loading: loadingInstalled, error: errorInstalled, refresh } = useInstalledPlugins(agentBase.id) + + // Plugin actions + const { install, uninstall, installing, uninstalling } = usePluginActions(agentBase.id, refresh) + + // Handle install action + const handleInstall = useCallback( + async (sourcePath: string, type: 'agent' | 'command' | 'skill') => { + const result = await install(sourcePath, type) + + if (result.success) { + window.toast.success(t('agent.settings.plugins.success.install')) + } else { + window.toast.error(t('agent.settings.plugins.error.install') + (result.error ? ': ' + result.error : '')) + } + }, + [install, t] + ) + + // Handle uninstall action + const handleUninstall = useCallback( + async (filename: string, type: 'agent' | 'command' | 'skill') => { + const result = await uninstall(filename, type) + + if (result.success) { + window.toast.success(t('agent.settings.plugins.success.uninstall')) + } else { + window.toast.error(t('agent.settings.plugins.error.uninstall') + (result.error ? ': ' + result.error : '')) + } + }, + [uninstall, t] + ) + + return ( + + + +
+ {errorAvailable ? ( + + +

+ {t('agent.settings.plugins.error.load')}: {errorAvailable} +

+
+
+ ) : ( + + )} +
+
+ + +
+ {errorInstalled ? ( + + +

+ {t('agent.settings.plugins.error.load')}: {errorInstalled} +

+
+
+ ) : ( + + )} +
+
+
+
+ ) +} + +export default PluginSettings diff --git a/src/renderer/src/pages/settings/AgentSettings/components/CategoryFilter.tsx b/src/renderer/src/pages/settings/AgentSettings/components/CategoryFilter.tsx new file mode 100644 index 0000000000..0530c05039 --- /dev/null +++ b/src/renderer/src/pages/settings/AgentSettings/components/CategoryFilter.tsx @@ -0,0 +1,53 @@ +import { Chip } from '@heroui/react' +import type { FC } from 'react' +import { useTranslation } from 'react-i18next' + +export interface CategoryFilterProps { + categories: string[] + selectedCategories: string[] + onChange: (categories: string[]) => void +} + +export const CategoryFilter: FC = ({ categories, selectedCategories, onChange }) => { + const { t } = useTranslation() + + const isAllSelected = selectedCategories.length === 0 + + const handleCategoryClick = (category: string) => { + if (selectedCategories.includes(category)) { + onChange(selectedCategories.filter((c) => c !== category)) + } else { + onChange([...selectedCategories, category]) + } + } + + const handleAllClick = () => { + onChange([]) + } + + return ( +
+ + {t('plugins.all_categories')} + + + {categories.map((category) => { + const isSelected = selectedCategories.includes(category) + return ( + handleCategoryClick(category)} + className="cursor-pointer"> + {category} + + ) + })} +
+ ) +} diff --git a/src/renderer/src/pages/settings/AgentSettings/components/InstalledPluginsList.tsx b/src/renderer/src/pages/settings/AgentSettings/components/InstalledPluginsList.tsx new file mode 100644 index 0000000000..79e9fcc6fb --- /dev/null +++ b/src/renderer/src/pages/settings/AgentSettings/components/InstalledPluginsList.tsx @@ -0,0 +1,99 @@ +import { Button, Chip, Skeleton, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow } from '@heroui/react' +import type { InstalledPlugin } from '@renderer/types/plugin' +import { Trash2 } from 'lucide-react' +import type { FC } from 'react' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' + +export interface InstalledPluginsListProps { + plugins: InstalledPlugin[] + onUninstall: (filename: string, type: 'agent' | 'command' | 'skill') => void + loading: boolean +} + +export const InstalledPluginsList: FC = ({ plugins, onUninstall, loading }) => { + const { t } = useTranslation() + const [uninstallingPlugin, setUninstallingPlugin] = useState(null) + + const handleUninstall = useCallback( + (plugin: InstalledPlugin) => { + const confirmed = window.confirm( + t('plugins.confirm_uninstall', { name: plugin.metadata.name || plugin.filename }) + ) + + if (confirmed) { + setUninstallingPlugin(plugin.filename) + onUninstall(plugin.filename, plugin.type) + // Reset after a delay to allow the operation to complete + setTimeout(() => setUninstallingPlugin(null), 2000) + } + }, + [onUninstall, t] + ) + + if (loading) { + return ( +
+ + + +
+ ) + } + + if (plugins.length === 0) { + return ( +
+

{t('plugins.no_installed_plugins')}

+

{t('plugins.install_plugins_from_browser')}

+
+ ) + } + + return ( +
+ + {t('plugins.name')} + {t('plugins.type')} + {t('plugins.category')} + {t('plugins.actions')} + + + {plugins.map((plugin) => ( + + +
+ {plugin.metadata.name} + {plugin.metadata.description && ( + {plugin.metadata.description} + )} +
+
+ + + {plugin.type} + + + + + {plugin.metadata.category} + + + + + +
+ ))} +
+
+ ) +} diff --git a/src/renderer/src/pages/settings/AgentSettings/components/PluginBrowser.tsx b/src/renderer/src/pages/settings/AgentSettings/components/PluginBrowser.tsx new file mode 100644 index 0000000000..c41f642365 --- /dev/null +++ b/src/renderer/src/pages/settings/AgentSettings/components/PluginBrowser.tsx @@ -0,0 +1,228 @@ +import { Input, Pagination, Tab, Tabs } from '@heroui/react' +import type { InstalledPlugin, PluginMetadata } from '@renderer/types/plugin' +import { Search } from 'lucide-react' +import type { FC } from 'react' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { CategoryFilter } from './CategoryFilter' +import { PluginCard } from './PluginCard' +import { PluginDetailModal } from './PluginDetailModal' + +export interface PluginBrowserProps { + agentId: string + agents: PluginMetadata[] + commands: PluginMetadata[] + skills: PluginMetadata[] + installedPlugins: InstalledPlugin[] + onInstall: (sourcePath: string, type: 'agent' | 'command' | 'skill') => void + onUninstall: (filename: string, type: 'agent' | 'command' | 'skill') => void + loading: boolean +} + +type PluginType = 'all' | 'agent' | 'command' | 'skill' + +const ITEMS_PER_PAGE = 12 + +export const PluginBrowser: FC = ({ + agentId, + agents, + commands, + skills, + installedPlugins, + onInstall, + onUninstall, + loading +}) => { + const { t } = useTranslation() + const [searchQuery, setSearchQuery] = useState('') + const [selectedCategories, setSelectedCategories] = useState([]) + const [activeType, setActiveType] = useState('all') + const [currentPage, setCurrentPage] = useState(1) + const [actioningPlugin, setActioningPlugin] = useState(null) + const [selectedPlugin, setSelectedPlugin] = useState(null) + const [isModalOpen, setIsModalOpen] = useState(false) + + // Combine all plugins based on active type + const allPlugins = useMemo(() => { + switch (activeType) { + case 'agent': + return agents + case 'command': + return commands + case 'skill': + return skills + case 'all': + default: + return [...agents, ...commands, ...skills] + } + }, [agents, commands, skills, activeType]) + + // Extract all unique categories + const allCategories = useMemo(() => { + const categories = new Set() + allPlugins.forEach((plugin) => { + if (plugin.category) { + categories.add(plugin.category) + } + }) + return Array.from(categories).sort() + }, [allPlugins]) + + // Filter plugins based on search query and selected categories + const filteredPlugins = useMemo(() => { + return allPlugins.filter((plugin) => { + // Filter by search query + const searchLower = searchQuery.toLowerCase() + const matchesSearch = + !searchQuery || + plugin.name.toLowerCase().includes(searchLower) || + plugin.description?.toLowerCase().includes(searchLower) || + plugin.tags?.some((tag) => tag.toLowerCase().includes(searchLower)) + + // Filter by selected categories + const matchesCategory = selectedCategories.length === 0 || selectedCategories.includes(plugin.category) + + return matchesSearch && matchesCategory + }) + }, [allPlugins, searchQuery, selectedCategories]) + + // Paginate filtered plugins + const paginatedPlugins = useMemo(() => { + const startIndex = (currentPage - 1) * ITEMS_PER_PAGE + const endIndex = startIndex + ITEMS_PER_PAGE + return filteredPlugins.slice(startIndex, endIndex) + }, [filteredPlugins, currentPage]) + + const totalPages = Math.ceil(filteredPlugins.length / ITEMS_PER_PAGE) + + // Check if a plugin is installed + const isPluginInstalled = (plugin: PluginMetadata): boolean => { + return installedPlugins.some( + (installed) => installed.filename === plugin.filename && installed.type === plugin.type + ) + } + + // Handle install with loading state + const handleInstall = async (plugin: PluginMetadata) => { + setActioningPlugin(plugin.sourcePath) + await onInstall(plugin.sourcePath, plugin.type) + setActioningPlugin(null) + } + + // Handle uninstall with loading state + const handleUninstall = async (plugin: PluginMetadata) => { + setActioningPlugin(plugin.sourcePath) + await onUninstall(plugin.filename, plugin.type) + setActioningPlugin(null) + } + + // Reset to first page when filters change + const handleSearchChange = (value: string) => { + setSearchQuery(value) + setCurrentPage(1) + } + + const handleCategoryChange = (categories: string[]) => { + setSelectedCategories(categories) + setCurrentPage(1) + } + + const handleTypeChange = (type: string | number) => { + setActiveType(type as PluginType) + setCurrentPage(1) + } + + const handlePluginClick = (plugin: PluginMetadata) => { + setSelectedPlugin(plugin) + setIsModalOpen(true) + } + + const handleModalClose = () => { + setIsModalOpen(false) + setSelectedPlugin(null) + } + + return ( +
+ {/* Search Input */} + } + isClearable + classNames={{ + input: 'text-small', + inputWrapper: 'h-10' + }} + /> + + {/* Category Filter */} + + + {/* Type Tabs */} + + + + + + + + {/* Result Count */} +
+

{t('plugins.showing_results', { count: filteredPlugins.length })}

+
+ + {/* Plugin Grid */} + {paginatedPlugins.length === 0 ? ( +
+

{t('plugins.no_results')}

+

{t('plugins.try_different_search')}

+
+ ) : ( +
+ {paginatedPlugins.map((plugin) => { + const installed = isPluginInstalled(plugin) + const isActioning = actioningPlugin === plugin.sourcePath + + return ( + handleInstall(plugin)} + onUninstall={() => handleUninstall(plugin)} + loading={loading || isActioning} + onClick={() => handlePluginClick(plugin)} + /> + ) + })} +
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+ +
+ )} + + {/* Plugin Detail Modal */} + selectedPlugin && handleInstall(selectedPlugin)} + onUninstall={() => selectedPlugin && handleUninstall(selectedPlugin)} + loading={selectedPlugin ? actioningPlugin === selectedPlugin.sourcePath : false} + /> +
+ ) +} diff --git a/src/renderer/src/pages/settings/AgentSettings/components/PluginCard.tsx b/src/renderer/src/pages/settings/AgentSettings/components/PluginCard.tsx new file mode 100644 index 0000000000..5ed5d4d1eb --- /dev/null +++ b/src/renderer/src/pages/settings/AgentSettings/components/PluginCard.tsx @@ -0,0 +1,83 @@ +import { Button, Card, CardBody, CardFooter, CardHeader, Chip, Spinner } from '@heroui/react' +import type { PluginMetadata } from '@renderer/types/plugin' +import { Download, Trash2 } from 'lucide-react' +import type { FC } from 'react' +import { useTranslation } from 'react-i18next' + +export interface PluginCardProps { + plugin: PluginMetadata + installed: boolean + onInstall: () => void + onUninstall: () => void + loading: boolean + onClick: () => void +} + +export const PluginCard: FC = ({ plugin, installed, onInstall, onUninstall, loading, onClick }) => { + const { t } = useTranslation() + + return ( + + +
+

{plugin.name}

+ + {plugin.type} + +
+ + {plugin.category} + +
+ + +

{plugin.description || t('plugins.no_description')}

+ + {plugin.tags && plugin.tags.length > 0 && ( +
+ {plugin.tags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+ + + {installed ? ( + + ) : ( + + )} + +
+ ) +} diff --git a/src/renderer/src/pages/settings/AgentSettings/components/PluginDetailModal.tsx b/src/renderer/src/pages/settings/AgentSettings/components/PluginDetailModal.tsx new file mode 100644 index 0000000000..948b0c2123 --- /dev/null +++ b/src/renderer/src/pages/settings/AgentSettings/components/PluginDetailModal.tsx @@ -0,0 +1,321 @@ +import { + Button, + Chip, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + Spinner, + Textarea +} from '@heroui/react' +import type { PluginMetadata } from '@renderer/types/plugin' +import { Download, Edit, Save, Trash2, X } from 'lucide-react' +import type { FC } from 'react' +import { useEffect, useState } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' + +export interface PluginDetailModalProps { + agentId: string + plugin: PluginMetadata | null + isOpen: boolean + onClose: () => void + installed: boolean + onInstall: () => void + onUninstall: () => void + loading: boolean +} + +export const PluginDetailModal: FC = ({ + agentId, + plugin, + isOpen, + onClose, + installed, + onInstall, + onUninstall, + loading +}) => { + const { t } = useTranslation() + const [content, setContent] = useState('') + const [contentLoading, setContentLoading] = useState(false) + const [contentError, setContentError] = useState(null) + const [isEditing, setIsEditing] = useState(false) + const [editedContent, setEditedContent] = useState('') + const [saving, setSaving] = useState(false) + + // Fetch plugin content when modal opens or plugin changes + useEffect(() => { + if (!isOpen || !plugin) { + setContent('') + setContentError(null) + setIsEditing(false) + setEditedContent('') + return + } + + const fetchContent = async () => { + setContentLoading(true) + setContentError(null) + setIsEditing(false) + setEditedContent('') + try { + let sourcePath = plugin.sourcePath + if (plugin.type === 'skill') { + sourcePath = sourcePath + '/' + 'SKILL.md' + } + + const result = await window.api.claudeCodePlugin.readContent(sourcePath) + if (result.success) { + setContent(result.data) + } else { + setContentError(`Failed to load content: ${result.error.type}`) + } + } catch (error) { + setContentError(`Error loading content: ${error instanceof Error ? error.message : String(error)}`) + } finally { + setContentLoading(false) + } + } + + fetchContent() + }, [isOpen, plugin]) + + const handleEdit = () => { + setEditedContent(content) + setIsEditing(true) + } + + const handleCancelEdit = () => { + setIsEditing(false) + setEditedContent('') + } + + const handleSave = async () => { + if (!plugin) return + + setSaving(true) + try { + const result = await window.api.claudeCodePlugin.writeContent({ + agentId, + filename: plugin.filename, + type: plugin.type, + content: editedContent + }) + + if (result.success) { + setContent(editedContent) + setIsEditing(false) + window.toast?.success('Plugin content saved successfully') + } else { + window.toast?.error(`Failed to save: ${result.error.type}`) + } + } catch (error) { + window.toast?.error(`Error saving: ${error instanceof Error ? error.message : String(error)}`) + } finally { + setSaving(false) + } + } + + if (!plugin) return null + + const modalContent = ( + + + +
+

{plugin.name}

+ + {plugin.type} + +
+
+ + {plugin.category} + + {plugin.version && ( + + v{plugin.version} + + )} +
+
+ + + {/* Description */} + {plugin.description && ( +
+

Description

+

{plugin.description}

+
+ )} + + {/* Author */} + {plugin.author && ( +
+

Author

+

{plugin.author}

+
+ )} + + {/* Tools (for agents) */} + {plugin.tools && plugin.tools.length > 0 && ( +
+

Tools

+
+ {plugin.tools.map((tool) => ( + + {tool} + + ))} +
+
+ )} + + {/* Allowed Tools (for commands) */} + {plugin.allowed_tools && plugin.allowed_tools.length > 0 && ( +
+

Allowed Tools

+
+ {plugin.allowed_tools.map((tool) => ( + + {tool} + + ))} +
+
+ )} + + {/* Tags */} + {plugin.tags && plugin.tags.length > 0 && ( +
+

Tags

+
+ {plugin.tags.map((tag) => ( + + {tag} + + ))} +
+
+ )} + + {/* Metadata */} +
+

Metadata

+
+
+ File: + {plugin.filename} +
+
+ Size: + {(plugin.size / 1024).toFixed(2)} KB +
+
+ Source: + {plugin.sourcePath} +
+ {plugin.installedAt && ( +
+ Installed: + {new Date(plugin.installedAt).toLocaleString()} +
+ )} +
+
+ + {/* Content */} +
+
+

Content

+ {installed && !contentLoading && !contentError && ( +
+ {isEditing ? ( + <> + + + + ) : ( + + )} +
+ )} +
+ {contentLoading ? ( +
+ +
+ ) : contentError ? ( +
{contentError}
+ ) : isEditing ? ( +