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 5e37489f2..057443aa4 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 748c48f60..2716815ba 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 b86350d70..94c2e3bae 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 27fc176c5..8e31ee7d9 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -64,6 +64,12 @@ asarUnpack: - resources/** - "**/*.{metal,exp,lib}" - "node_modules/@img/sharp-libvips-*/**" + +# copy from node_modules/claude-code-plugins/plugins to resources/data/claude-code-pluginso +extraResources: + - 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 8da959d18..0e0176e95 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,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", @@ -86,6 +86,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", @@ -195,6 +197,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", @@ -234,6 +237,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 293101511..75049ade0 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -350,5 +350,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 f1a4de6a5..cf0892f93 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -11,6 +11,7 @@ import { handleZoomFactor } from '@main/utils/zoom' import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, UpgradeChannel } from '@shared/config/constant' import { IpcChannel } from '@shared/IpcChannel' +import type { PluginError } from '@types' import { AgentPersistedMessage, FileMetadata, @@ -46,6 +47,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' @@ -93,6 +95,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() @@ -890,4 +904,117 @@ 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 } + } + }) } diff --git a/src/main/services/PluginService.ts b/src/main/services/PluginService.ts new file mode 100644 index 000000000..8ff182020 --- /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/utils/fileOperations.ts b/src/main/utils/fileOperations.ts new file mode 100644 index 000000000..6352126e2 --- /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 000000000..9c3d7c954 --- /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 900456004..3dce6c009 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -35,6 +35,15 @@ import { import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron' import { CreateDirectoryOptions } from 'webdav' +import type { + InstalledPlugin, + InstallPluginOptions, + ListAvailablePluginsResult, + PluginMetadata, + PluginResult, + UninstallPluginOptions, + WritePluginContentOptions +} from '../renderer/src/types/plugin' import type { ActionItem } from '../renderer/src/types/selectionTypes' export function tracedInvoke(channel: string, spanContext: SpanContext | undefined, ...args: any[]) { @@ -507,6 +516,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/hooks/usePlugins.ts b/src/renderer/src/hooks/usePlugins.ts new file mode 100644 index 000000000..e4807779e --- /dev/null +++ b/src/renderer/src/hooks/usePlugins.ts @@ -0,0 +1,163 @@ +import type { InstalledPlugin, PluginError, PluginMetadata } from '@renderer/types/plugin' +import { useCallback, useEffect, useState } from 'react' + +/** + * Helper to extract error message from PluginError union type + */ +function getPluginErrorMessage(error: PluginError, defaultMessage: string): string { + if ('message' in error && error.message) return error.message + if ('reason' in error) return error.reason + if ('path' in error) return `Error with file: ${error.path}` + return defaultMessage +} + +/** + * Hook to fetch and cache available plugins from the resources directory + * @returns Object containing available agents, commands, skills, loading state, and error + */ +export function useAvailablePlugins() { + const [agents, setAgents] = useState([]) + const [commands, setCommands] = useState([]) + const [skills, setSkills] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + const fetchAvailablePlugins = async () => { + setLoading(true) + setError(null) + + try { + const result = await window.api.claudeCodePlugin.listAvailable() + + if (result.success) { + setAgents(result.data.agents) + setCommands(result.data.commands) + setSkills(result.data.skills) + } else { + setError(getPluginErrorMessage(result.error, 'Failed to load available plugins')) + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error occurred') + } finally { + setLoading(false) + } + } + + fetchAvailablePlugins() + }, []) + + return { agents, commands, skills, loading, error } +} + +/** + * Hook to fetch installed plugins for a specific agent + * @param agentId - The ID of the agent to fetch plugins for + * @returns Object containing installed plugins, loading state, error, and refresh function + */ +export function useInstalledPlugins(agentId: string | undefined) { + const [plugins, setPlugins] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const refresh = useCallback(async () => { + if (!agentId) { + setPlugins([]) + setLoading(false) + setError(null) + return + } + + setLoading(true) + setError(null) + + try { + const result = await window.api.claudeCodePlugin.listInstalled(agentId) + + if (result.success) { + setPlugins(result.data) + } else { + setError(getPluginErrorMessage(result.error, 'Failed to load installed plugins')) + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error occurred') + } finally { + setLoading(false) + } + }, [agentId]) + + useEffect(() => { + refresh() + }, [refresh]) + + return { plugins, loading, error, refresh } +} + +/** + * Hook to provide install and uninstall actions for plugins + * @param agentId - The ID of the agent to perform actions for + * @param onSuccess - Optional callback to be called on successful operations + * @returns Object containing install, uninstall functions and their loading states + */ +export function usePluginActions(agentId: string, onSuccess?: () => void) { + const [installing, setInstalling] = useState(false) + const [uninstalling, setUninstalling] = useState(false) + + const install = useCallback( + async (sourcePath: string, type: 'agent' | 'command' | 'skill') => { + setInstalling(true) + + try { + const result = await window.api.claudeCodePlugin.install({ + agentId, + sourcePath, + type + }) + + if (result.success) { + onSuccess?.() + return { success: true as const, data: result.data } + } else { + const errorMessage = getPluginErrorMessage(result.error, 'Failed to install plugin') + return { success: false as const, error: errorMessage } + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred' + return { success: false as const, error: errorMessage } + } finally { + setInstalling(false) + } + }, + [agentId, onSuccess] + ) + + const uninstall = useCallback( + async (filename: string, type: 'agent' | 'command' | 'skill') => { + setUninstalling(true) + + try { + const result = await window.api.claudeCodePlugin.uninstall({ + agentId, + filename, + type + }) + + if (result.success) { + onSuccess?.() + return { success: true as const } + } else { + const errorMessage = getPluginErrorMessage(result.error, 'Failed to uninstall plugin') + return { success: false as const, error: errorMessage } + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred' + return { success: false as const, error: errorMessage } + } finally { + setUninstalling(false) + } + }, + [agentId, onSuccess] + ) + + return { install, uninstall, installing, uninstalling } +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index a5a93e435..a7cc809da 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -107,6 +107,50 @@ "title": "Advanced Settings" }, "essential": "Essential Settings", + "plugins": { + "available": { + "title": "Available Plugins" + }, + "confirm": { + "uninstall": "Are you sure you want to uninstall this plugin?" + }, + "empty": { + "available": "No plugins found matching your filters. Try adjusting your search or category filters." + }, + "error": { + "install": "Failed to install plugin", + "load": "Failed to load plugins", + "uninstall": "Failed to uninstall plugin" + }, + "filter": { + "all": "All Categories" + }, + "install": "Install", + "installed": { + "empty": "No plugins installed yet. Browse available plugins to get started.", + "title": "Installed Plugins" + }, + "installing": "Installing...", + "results": "{{count}} plugin(s) found", + "search": { + "placeholder": "Search plugins..." + }, + "success": { + "install": "Plugin installed successfully", + "uninstall": "Plugin uninstalled successfully" + }, + "tab": "Plugins", + "type": { + "agent": "Agent", + "agents": "Agents", + "all": "All", + "command": "Command", + "commands": "Commands", + "skills": "Skills" + }, + "uninstall": "Uninstall", + "uninstalling": "Uninstalling..." + }, "prompt": "Prompt Settings", "tooling": { "mcp": { @@ -2299,6 +2343,32 @@ "seed_tip": "Controls upscaling randomness" } }, + "plugins": { + "actions": "Actions", + "agents": "Agents", + "all_categories": "All Categories", + "all_types": "All", + "category": "Category", + "commands": "Commands", + "confirm_uninstall": "Are you sure you want to uninstall {{name}}?", + "install": "Install", + "install_plugins_from_browser": "Browse available plugins to get started", + "installing": "Installing...", + "name": "Name", + "no_description": "No description available", + "no_installed_plugins": "No plugins installed yet", + "no_results": "No plugins found", + "search_placeholder": "Search plugins...", + "showing_results": "Showing {{count}} plugin", + "showing_results_one": "Showing {{count}} plugin", + "showing_results_other": "Showing {{count}} plugins", + "showing_results_plural": "Showing {{count}} plugins", + "skills": "Skills", + "try_different_search": "Try adjusting your search or category filters", + "type": "Type", + "uninstall": "Uninstall", + "uninstalling": "Uninstalling..." + }, "preview": { "copy": { "image": "Copy as image" diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 44f051be0..20708a3b8 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -107,6 +107,50 @@ "title": "高级设置" }, "essential": "基础设置", + "plugins": { + "available": { + "title": "可用插件" + }, + "confirm": { + "uninstall": "确定要卸载此插件吗?" + }, + "empty": { + "available": "未找到匹配的插件。请尝试调整搜索或类别筛选。" + }, + "error": { + "install": "安装插件失败", + "load": "加载插件失败", + "uninstall": "卸载插件失败" + }, + "filter": { + "all": "所有类别" + }, + "install": "安装", + "installed": { + "empty": "尚未安装任何插件。浏览可用插件以开始使用。", + "title": "已安装插件" + }, + "installing": "安装中...", + "results": "找到 {{count}} 个插件", + "search": { + "placeholder": "搜索插件..." + }, + "success": { + "install": "插件安装成功", + "uninstall": "插件卸载成功" + }, + "tab": "插件", + "type": { + "agent": "代理", + "agents": "代理", + "all": "全部", + "command": "命令", + "commands": "命令", + "skills": "技能" + }, + "uninstall": "卸载", + "uninstalling": "卸载中..." + }, "prompt": "提示词设置", "tooling": { "mcp": { @@ -2299,6 +2343,32 @@ "seed_tip": "控制放大结果的随机性" } }, + "plugins": { + "actions": "操作", + "agents": "代理", + "all_categories": "所有类别", + "all_types": "全部", + "category": "类别", + "commands": "命令", + "confirm_uninstall": "确定要卸载 {{name}} 吗?", + "install": "安装", + "install_plugins_from_browser": "浏览可用插件以开始使用", + "installing": "安装中...", + "name": "名称", + "no_description": "无描述", + "no_installed_plugins": "尚未安装任何插件", + "no_results": "未找到插件", + "search_placeholder": "搜索插件...", + "showing_results": "显示 {{count}} 个插件", + "showing_results_one": "显示 {{count}} 个插件", + "showing_results_other": "显示 {{count}} 个插件", + "showing_results_plural": "显示 {{count}} 个插件", + "skills": "技能", + "try_different_search": "请尝试调整搜索或类别筛选", + "type": "类型", + "uninstall": "卸载", + "uninstalling": "卸载中..." + }, "preview": { "copy": { "image": "复制为图片" diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index d933db01d..a3e762be9 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -107,6 +107,50 @@ "title": "進階設定" }, "essential": "必要設定", + "plugins": { + "available": { + "title": "可用外掛" + }, + "confirm": { + "uninstall": "確定要解除安裝此外掛嗎?" + }, + "empty": { + "available": "未找到符合的外掛。請嘗試調整搜尋或類別篩選。" + }, + "error": { + "install": "安裝外掛失敗", + "load": "載入外掛失敗", + "uninstall": "解除安裝外掛失敗" + }, + "filter": { + "all": "所有類別" + }, + "install": "安裝", + "installed": { + "empty": "尚未安裝任何外掛。瀏覽可用外掛以開始使用。", + "title": "已安裝外掛" + }, + "installing": "安裝中...", + "results": "找到 {{count}} 個外掛", + "search": { + "placeholder": "搜尋外掛..." + }, + "success": { + "install": "外掛安裝成功", + "uninstall": "外掛解除安裝成功" + }, + "tab": "外掛", + "type": { + "agent": "代理", + "agents": "代理", + "all": "全部", + "command": "指令", + "commands": "指令", + "skills": "技能" + }, + "uninstall": "解除安裝", + "uninstalling": "解除安裝中..." + }, "prompt": "提示設定", "tooling": { "mcp": { @@ -2299,6 +2343,32 @@ "seed_tip": "控制放大結果的隨機性" } }, + "plugins": { + "actions": "操作", + "agents": "代理", + "all_categories": "所有類別", + "all_types": "全部", + "category": "類別", + "commands": "指令", + "confirm_uninstall": "確定要解除安裝 {{name}} 嗎?", + "install": "安裝", + "install_plugins_from_browser": "瀏覽可用外掛以開始使用", + "installing": "安裝中...", + "name": "名稱", + "no_description": "無描述", + "no_installed_plugins": "尚未安裝任何外掛", + "no_results": "未找到外掛", + "search_placeholder": "搜尋外掛...", + "showing_results": "顯示 {{count}} 個外掛", + "showing_results_one": "顯示 {{count}} 個外掛", + "showing_results_other": "顯示 {{count}} 個外掛", + "showing_results_plural": "顯示 {{count}} 個外掛", + "skills": "技能", + "try_different_search": "請嘗試調整搜尋或類別篩選", + "type": "類型", + "uninstall": "解除安裝", + "uninstalling": "解除安裝中..." + }, "preview": { "copy": { "image": "複製為圖片" diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index 3a44387d5..f8ddc9991 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -107,6 +107,50 @@ "title": "Erweiterte Einstellungen" }, "essential": "Grundeinstellungen", + "plugins": { + "available": { + "title": "Verfügbare Plugins" + }, + "confirm": { + "uninstall": "Sind Sie sicher, dass Sie dieses Plugin deinstallieren möchten?" + }, + "empty": { + "available": "Keine Plugins gefunden, die deinen Filtern entsprechen. Versuche, deine Such- oder Kategoriefilter anzupassen." + }, + "error": { + "install": "Fehler beim Installieren des Plugins", + "load": "Fehler beim Laden der Plugins", + "uninstall": "Fehler beim Deinstallieren des Plugins" + }, + "filter": { + "all": "Alle Kategorien" + }, + "install": "Installieren", + "installed": { + "empty": "Noch keine Plugins installiert. Durchsuche verfügbare Plugins, um loszulegen.", + "title": "Installierte Plugins" + }, + "installing": "Wird installiert...", + "results": "{{count}} Plugin(s) gefunden", + "search": { + "placeholder": "Such-Plugins..." + }, + "success": { + "install": "Plugin erfolgreich installiert", + "uninstall": "Plugin erfolgreich deinstalliert" + }, + "tab": "Plugins", + "type": { + "agent": "Agent", + "agents": "Agenten", + "all": "Alle", + "command": "Befehl", + "commands": "Befehle", + "skills": "Fähigkeiten" + }, + "uninstall": "Deinstallieren", + "uninstalling": "Deinstallation läuft..." + }, "prompt": "Prompt-Einstellungen", "tooling": { "mcp": { @@ -2299,6 +2343,32 @@ "seed_tip": "Kontrolle der Zufälligkeit des Upscale-Ergebnisses" } }, + "plugins": { + "actions": "Aktionen", + "agents": "Agenten", + "all_categories": "Alle Kategorien", + "all_types": "Alle", + "category": "Kategorie", + "commands": "Befehle", + "confirm_uninstall": "Sind Sie sicher, dass Sie {{name}} deinstallieren möchten?", + "install": "Installieren", + "install_plugins_from_browser": "Durchsuche verfügbare Plugins, um loszulegen", + "installing": "Installiere…", + "name": "Name", + "no_description": "Keine Beschreibung verfügbar", + "no_installed_plugins": "Noch keine Plugins installiert", + "no_results": "Keine Plugins gefunden", + "search_placeholder": "Such-Plugins...", + "showing_results": "{{count}} Plugin anzeigen", + "showing_results_one": "{{count}} Plugin anzeigen", + "showing_results_other": "Zeige {{count}} Plugins", + "showing_results_plural": "{{count}} Plugins anzeigen", + "skills": "Fähigkeiten", + "try_different_search": "Versuchen Sie, Ihre Suche oder die Kategoriefilter anzupassen.", + "type": "Typ", + "uninstall": "Deinstallieren", + "uninstalling": "Deinstallation läuft..." + }, "preview": { "copy": { "image": "Als Bild kopieren" diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 59b25aea2..0c85886c1 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -107,6 +107,50 @@ "title": "Ρυθμίσεις για προχωρημένους" }, "essential": "Βασικές Ρυθμίσεις", + "plugins": { + "available": { + "title": "Διαθέσιμα πρόσθετα" + }, + "confirm": { + "uninstall": "Είστε βέβαιοι ότι θέλετε να απεγκαταστήσετε αυτό το πρόσθετο;" + }, + "empty": { + "available": "Δεν βρέθηκε συμβατό πρόσθετο. Δοκιμάστε να προσαρμόσετε την αναζήτηση ή το φίλτρο κατηγοριών." + }, + "error": { + "install": "Η εγκατάσταση του πρόσθετου απέτυχε", + "load": "Η φόρτωση του πρόσθετου απέτυχε", + "uninstall": "Η απεγκατάσταση του πρόσθετου απέτυχε" + }, + "filter": { + "all": "Όλες οι κατηγορίες" + }, + "install": "εγκατάσταση", + "installed": { + "empty": "Δεν έχει εγκατασταθεί κανένα πρόσθετο. Περιηγηθείτε στα διαθέσιμα πρόσθετα για να ξεκινήσετε.", + "title": "Έχει εγκατασταθεί το πρόσθετο" + }, + "installing": "Εγκατάσταση...", + "results": "Βρέθηκαν {{count}} πρόσθετα", + "search": { + "placeholder": "Αναζήτηση πρόσθετου..." + }, + "success": { + "install": "Η εγκατάσταση του πρόσθετου ολοκληρώθηκε με επιτυχία", + "uninstall": "Η απεγκατάσταση του πρόσθετου ολοκληρώθηκε με επιτυχία" + }, + "tab": "Πρόσθετο", + "type": { + "agent": "αντιπρόσωπος", + "agents": "αντιπρόσωπος", + "all": "όλα", + "command": "εντολή", + "commands": "εντολή", + "skills": "δεξιότητα" + }, + "uninstall": "απεγκατάσταση", + "uninstalling": "Απεγκατάσταση..." + }, "prompt": "Ρυθμίσεις Προτροπής", "tooling": { "mcp": { @@ -2299,6 +2343,32 @@ "seed_tip": "Ελέγχει την τυχαιότητα του αποτελέσματος μεγέθυνσης" } }, + "plugins": { + "actions": "Λειτουργία", + "agents": "αντιπρόσωπος", + "all_categories": "Όλες οι κατηγορίες", + "all_types": "ολόκληρο", + "category": "Κατηγορία", + "commands": "εντολή", + "confirm_uninstall": "Είστε σίγουροι ότι θέλετε να απεγκαταστήσετε το {{name}};", + "install": "εγκατάσταση", + "install_plugins_from_browser": "Περιηγηθείτε στα διαθέσιμα πρόσθετα για να ξεκινήσετε", + "installing": "Εγκατάσταση...", + "name": "Όνομα", + "no_description": "Χωρίς περιγραφή", + "no_installed_plugins": "Δεν έχει εγκατασταθεί κανένα πρόσθετο", + "no_results": "Δεν βρέθηκε πρόσθετο", + "search_placeholder": "Πρόσθετο αναζήτησης...", + "showing_results": "Εμφάνιση {{count}} προσθέτων", + "showing_results_one": "Εμφάνιση {{count}} προσθέτων", + "showing_results_other": "Εμφάνιση {{count}} προσθέτων", + "showing_results_plural": "Εμφάνιση {{count}} πρόσθετων", + "skills": "δεξιότητα", + "try_different_search": "Προσπαθήστε να προσαρμόσετε την αναζήτηση ή το φίλτρο κατηγοριών", + "type": "τύπος", + "uninstall": "κατάργηση εγκατάστασης", + "uninstalling": "Απεγκατάσταση..." + }, "preview": { "copy": { "image": "Αντιγραφή ως εικόνα" diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 70defe51d..d09654448 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -107,6 +107,50 @@ "title": "Configuración avanzada" }, "essential": "Configuraciones esenciales", + "plugins": { + "available": { + "title": "Complementos disponibles" + }, + "confirm": { + "uninstall": "¿Estás seguro de que quieres desinstalar este complemento?" + }, + "empty": { + "available": "No se encontró ningún complemento que coincida. Intenta ajustar la búsqueda o los filtros de categoría." + }, + "error": { + "install": "Error al instalar el complemento", + "load": "Error al cargar el complemento", + "uninstall": "Error al desinstalar el complemento" + }, + "filter": { + "all": "Todas las categorías" + }, + "install": "instalación", + "installed": { + "empty": "Aún no se ha instalado ningún complemento. Explora los complementos disponibles para comenzar.", + "title": "Complemento instalado" + }, + "installing": "Instalando...", + "results": "Encontrados {{count}} complementos", + "search": { + "placeholder": "Buscar complemento..." + }, + "success": { + "install": "Complemento instalado con éxito", + "uninstall": "Complemento desinstalado correctamente" + }, + "tab": "complemento", + "type": { + "agent": "agente", + "agents": "Agente", + "all": "todo", + "command": "comando", + "commands": "comando", + "skills": "habilidad" + }, + "uninstall": "Desinstalar", + "uninstalling": "Desinstalando..." + }, "prompt": "Configuración de indicaciones", "tooling": { "mcp": { @@ -2299,6 +2343,32 @@ "seed_tip": "Controla la aleatoriedad del resultado de la ampliación" } }, + "plugins": { + "actions": "Operación", + "agents": "Agente", + "all_categories": "Todas las categorías", + "all_types": "todo", + "category": "Categoría", + "commands": "comando", + "confirm_uninstall": "¿Estás seguro de que quieres desinstalar {{name}}?", + "install": "instalación", + "install_plugins_from_browser": "Explora los complementos disponibles para empezar a usar", + "installing": "Instalando...", + "name": "Nombre", + "no_description": "Sin descripción", + "no_installed_plugins": "Aún no se ha instalado ningún complemento", + "no_results": "No se encontró el complemento", + "search_placeholder": "Buscar complemento...", + "showing_results": "Mostrar {{count}} complementos", + "showing_results_one": "Mostrar {{count}} complementos", + "showing_results_other": "Mostrar {{count}} complementos", + "showing_results_plural": "Mostrar {{count}} complementos", + "skills": "habilidad", + "try_different_search": "Por favor, intenta ajustar la búsqueda o los filtros de categoría.", + "type": "tipo", + "uninstall": "Desinstalar", + "uninstalling": "Desinstalando..." + }, "preview": { "copy": { "image": "Copiar como imagen" diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 305378447..17a6f622c 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -107,6 +107,50 @@ "title": "Paramètres avancés" }, "essential": "Paramètres essentiels", + "plugins": { + "available": { + "title": "Plugins disponibles" + }, + "confirm": { + "uninstall": "Êtes-vous sûr de vouloir désinstaller ce plugin ?" + }, + "empty": { + "available": "Aucun plugin correspondant trouvé. Veuillez essayer d’ajuster la recherche ou les filtres de catégorie." + }, + "error": { + "install": "Échec de l'installation du plugin", + "load": "Échec du chargement du plugin", + "uninstall": "Échec de la désinstallation du plugin" + }, + "filter": { + "all": "Toutes les catégories" + }, + "install": "Installation", + "installed": { + "empty": "Aucun plugin n'est encore installé. Parcourez les plugins disponibles pour commencer.", + "title": "Extension installée" + }, + "installing": "Installation en cours...", + "results": "{{count}} modules complémentaires trouvés", + "search": { + "placeholder": "Recherche de plug-ins..." + }, + "success": { + "install": "Installation du plugin réussie", + "uninstall": "Désinstallation du plugin réussie" + }, + "tab": "Module d'extension", + "type": { + "agent": "mandataire", + "agents": "mandataire", + "all": "Tout", + "command": "commande", + "commands": "commande", + "skills": "compétence" + }, + "uninstall": "Désinstaller", + "uninstalling": "Désinstallation en cours..." + }, "prompt": "Paramètres de l'invite", "tooling": { "mcp": { @@ -2299,6 +2343,32 @@ "seed_tip": "Contrôle la randomisation du résultat d'agrandissement" } }, + "plugins": { + "actions": "Opération", + "agents": "mandataire", + "all_categories": "Toutes les catégories", + "all_types": "Tout", + "category": "Catégorie", + "commands": "commande", + "confirm_uninstall": "Êtes-vous sûr de vouloir désinstaller {{name}} ?", + "install": "Installation", + "install_plugins_from_browser": "Parcourir les plugins disponibles pour commencer", + "installing": "Installation en cours...", + "name": "Nom", + "no_description": "Sans description", + "no_installed_plugins": "Aucun plugin n’est encore installé", + "no_results": "Aucun plugin trouvé", + "search_placeholder": "Rechercher des modules d'extension...", + "showing_results": "Afficher {{count}} extensions", + "showing_results_one": "Afficher {{count}} modules d’extension", + "showing_results_other": "Afficher {{count}} modules d'extension", + "showing_results_plural": "Afficher {{count}} modules d'extension", + "skills": "compétence", + "try_different_search": "Veuillez essayer d’ajuster la recherche ou le filtre de catégorie.", + "type": "type", + "uninstall": "Désinstaller", + "uninstalling": "Désinstallation en cours..." + }, "preview": { "copy": { "image": "Copier en tant qu'image" diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 6e66ace09..25b0161c9 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -107,6 +107,50 @@ "title": "高級設定" }, "essential": "必須設定", + "plugins": { + "available": { + "title": "利用可能なプラグイン" + }, + "confirm": { + "uninstall": "このプラグインをアンインストールしてもよろしいですか?" + }, + "empty": { + "available": "一致するプラグインが見つかりませんでした。検索キーワードやカテゴリフィルターを調整してみてください。" + }, + "error": { + "install": "プラグインのインストールに失敗しました", + "load": "プラグインの読み込みに失敗しました", + "uninstall": "プラグインのアンインストールに失敗しました" + }, + "filter": { + "all": "すべてのカテゴリー" + }, + "install": "インストール", + "installed": { + "empty": "まだプラグインがインストールされていません。利用可能なプラグインを見てみましょう。", + "title": "インストール済みプラグイン" + }, + "installing": "インストール中...", + "results": "{{count}} 個のプラグインが見つかりました", + "search": { + "placeholder": "検索プラグイン..." + }, + "success": { + "install": "プラグインのインストールが成功しました", + "uninstall": "プラグインのアンインストールが成功しました" + }, + "tab": "プラグイン", + "type": { + "agent": "代理", + "agents": "代理", + "all": "全部", + "command": "命令", + "commands": "命令", + "skills": "技能" + }, + "uninstall": "アンインストール", + "uninstalling": "アンインストール中..." + }, "prompt": "プロンプト設定", "tooling": { "mcp": { @@ -2299,6 +2343,32 @@ "seed_tip": "拡大結果のランダム性を制御します" } }, + "plugins": { + "actions": "操作", + "agents": "代理", + "all_categories": "すべてのカテゴリー", + "all_types": "全部", + "category": "カテゴリー", + "commands": "命令", + "confirm_uninstall": "{{name}}をアンインストールしてもよろしいですか?", + "install": "インストール", + "install_plugins_from_browser": "利用可能なプラグインを閲覧して、使用を開始してください", + "installing": "インストール中...", + "name": "名称", + "no_description": "説明なし", + "no_installed_plugins": "まだプラグインがインストールされていません", + "no_results": "プラグインが見つかりません", + "search_placeholder": "検索プラグイン...", + "showing_results": "{{count}} 個のプラグインを表示", + "showing_results_one": "{{count}} 個のプラグインを表示", + "showing_results_other": "{{count}} 個のプラグインを表示", + "showing_results_plural": "{{count}} 個のプラグインを表示", + "skills": "スキル", + "try_different_search": "検索またはカテゴリフィルターを調整してみてください", + "type": "タイプ", + "uninstall": "アンインストール", + "uninstalling": "アンインストール中..." + }, "preview": { "copy": { "image": "画像としてコピー" diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 4a6dc5b2b..ebee0bb94 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -107,6 +107,50 @@ "title": "Configurações avançadas" }, "essential": "Configurações Essenciais", + "plugins": { + "available": { + "title": "Plugins disponíveis" + }, + "confirm": { + "uninstall": "Tem certeza de que deseja desinstalar este plugin?" + }, + "empty": { + "available": "Nenhum plugin correspondente encontrado. Tente ajustar a pesquisa ou os filtros de categoria." + }, + "error": { + "install": "Falha na instalação do plugin", + "load": "Falha ao carregar o plugin", + "uninstall": "Falha ao desinstalar o plug-in" + }, + "filter": { + "all": "Todas as categorias" + }, + "install": "Instalação", + "installed": { + "empty": "Nenhum plugin foi instalado ainda. Explore os plugins disponíveis para começar.", + "title": "Plugin instalado" + }, + "installing": "Instalando...", + "results": "Encontrados {{count}} plugins", + "search": { + "placeholder": "Pesquisar extensão..." + }, + "success": { + "install": "Plugin instalado com sucesso", + "uninstall": "插件 desinstalado com sucesso" + }, + "tab": "plug-in", + "type": { + "agent": "agente", + "agents": "agente", + "all": "tudo", + "command": "comando", + "commands": "comando", + "skills": "habilidade" + }, + "uninstall": "desinstalar", + "uninstalling": "Desinstalando..." + }, "prompt": "Configurações de Prompt", "tooling": { "mcp": { @@ -2299,6 +2343,32 @@ "seed_tip": "Controla a aleatoriedade do resultado de ampliação" } }, + "plugins": { + "actions": "Operação", + "agents": "agente", + "all_categories": "Todas as categorias", + "all_types": "Tudo", + "category": "categoria", + "commands": "comando", + "confirm_uninstall": "Tem certeza de que deseja desinstalar {{name}}?", + "install": "Instalação", + "install_plugins_from_browser": "Navegue pelos plugins disponíveis para começar a usar", + "installing": "Instalando...", + "name": "Nome", + "no_description": "Sem descrição", + "no_installed_plugins": "Nenhum plugin foi instalado ainda", + "no_results": "Plugin não encontrado", + "search_placeholder": "Pesquisar plugin...", + "showing_results": "Exibir {{count}} extensões", + "showing_results_one": "Mostrar {{count}} extensões", + "showing_results_other": "Exibir {{count}} extensões", + "showing_results_plural": "Exibir {{count}} extensões", + "skills": "habilidade", + "try_different_search": "Por favor, tente ajustar a pesquisa ou os filtros de categoria.", + "type": "tipo", + "uninstall": "Desinstalar", + "uninstalling": "Desinstalando..." + }, "preview": { "copy": { "image": "Copiar como imagem" diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 477fcb0a2..b16bbfcd6 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -107,6 +107,50 @@ "title": "Расширенные настройки" }, "essential": "Основные настройки", + "plugins": { + "available": { + "title": "Доступные плагины" + }, + "confirm": { + "uninstall": "Вы уверены, что хотите удалить этот плагин?" + }, + "empty": { + "available": "Совпадающие плагины не найдены. Попробуйте изменить поиск или фильтр категорий." + }, + "error": { + "install": "Ошибка установки плагина", + "load": "Ошибка загрузки плагина", + "uninstall": "Не удалось удалить плагин" + }, + "filter": { + "all": "Все категории" + }, + "install": "установка", + "installed": { + "empty": "Плагины ещё не установлены. Просмотрите доступные плагины, чтобы начать.", + "title": "Установленный плагин" + }, + "installing": "Установка...", + "results": "Найдено {{count}} плагинов", + "search": { + "placeholder": "Поиск плагинов..." + }, + "success": { + "install": "Плагин успешно установлен", + "uninstall": "Плагин успешно удалён" + }, + "tab": "плагин", + "type": { + "agent": "агент", + "agents": "Прокси", + "all": "всё", + "command": "команда", + "commands": "команда", + "skills": "навык" + }, + "uninstall": "Удаление", + "uninstalling": "Удаление..." + }, "prompt": "Настройки подсказки", "tooling": { "mcp": { @@ -2299,6 +2343,32 @@ "seed_tip": "Контролирует случайный характер увеличения изображений для воспроизводимых результатов" } }, + "plugins": { + "actions": "Операция", + "agents": "агент", + "all_categories": "Все категории", + "all_types": "всё", + "category": "категория", + "commands": "команда", + "confirm_uninstall": "Вы уверены, что хотите удалить {{name}}?", + "install": "установка", + "install_plugins_from_browser": "Просмотрите доступные плагины, чтобы начать работу", + "installing": "Установка...", + "name": "название", + "no_description": "Без описания", + "no_installed_plugins": "Плагины ещё не установлены", + "no_results": "Плагин не найден", + "search_placeholder": "Поиск плагинов...", + "showing_results": "Отображено {{count}} плагинов", + "showing_results_one": "Отображено {{count}} плагинов", + "showing_results_other": "Отображено {{count}} плагинов", + "showing_results_plural": "Отображение {{count}} плагинов", + "skills": "навык", + "try_different_search": "Пожалуйста, попробуйте изменить поиск или фильтры категорий", + "type": "тип", + "uninstall": "Удаление", + "uninstalling": "Удаление..." + }, "preview": { "copy": { "image": "Скопировать как изображение" diff --git a/src/renderer/src/pages/settings/AgentSettings/AgentSettingsPopup.tsx b/src/renderer/src/pages/settings/AgentSettings/AgentSettingsPopup.tsx index 516f0248e..589e7f48c 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 960a0bf2e..f067e6e0a 100644 --- a/src/renderer/src/pages/settings/AgentSettings/AvatarSetting.tsx +++ b/src/renderer/src/pages/settings/AgentSettings/AvatarSetting.tsx @@ -1,5 +1,5 @@ import { EmojiAvatarWithPicker } from '@renderer/components/Avatar/EmojiAvatarWithPicker' -import { AgentEntity, isAgentType, UpdateAgentForm } from '@renderer/types' +import { AgentConfigurationSchema, AgentEntity, isAgentType, UpdateAgentForm } from '@renderer/types' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -19,13 +19,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 000000000..3b54c15e1 --- /dev/null +++ b/src/renderer/src/pages/settings/AgentSettings/PluginSettings.tsx @@ -0,0 +1,114 @@ +import { Card, CardBody, Tab, Tabs } from '@heroui/react' +import { useAvailablePlugins, useInstalledPlugins, usePluginActions } from '@renderer/hooks/usePlugins' +import { GetAgentResponse, GetAgentSessionResponse, UpdateAgentBaseForm } from '@renderer/types/agent' +import { FC, 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 000000000..c9ecee281 --- /dev/null +++ b/src/renderer/src/pages/settings/AgentSettings/components/CategoryFilter.tsx @@ -0,0 +1,53 @@ +import { Chip } from '@heroui/react' +import { 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 000000000..4aa3015a4 --- /dev/null +++ b/src/renderer/src/pages/settings/AgentSettings/components/InstalledPluginsList.tsx @@ -0,0 +1,98 @@ +import { Button, Chip, Skeleton, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow } from '@heroui/react' +import { InstalledPlugin } from '@renderer/types/plugin' +import { Trash2 } from 'lucide-react' +import { FC, 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 000000000..3f034e5ec --- /dev/null +++ b/src/renderer/src/pages/settings/AgentSettings/components/PluginBrowser.tsx @@ -0,0 +1,227 @@ +import { Input, Pagination, Tab, Tabs } from '@heroui/react' +import { InstalledPlugin, PluginMetadata } from '@renderer/types/plugin' +import { Search } from 'lucide-react' +import { FC, 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 000000000..ddf89adad --- /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 { PluginMetadata } from '@renderer/types/plugin' +import { Download, Trash2 } from 'lucide-react' +import { 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 000000000..910cad444 --- /dev/null +++ b/src/renderer/src/pages/settings/AgentSettings/components/PluginDetailModal.tsx @@ -0,0 +1,320 @@ +import { + Button, + Chip, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + Spinner, + Textarea +} from '@heroui/react' +import { PluginMetadata } from '@renderer/types/plugin' +import { Download, Edit, Save, Trash2, X } from 'lucide-react' +import { FC, 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 ? ( +