From b586e1796e5db0935cfd011a24b86657e4e1b86f Mon Sep 17 00:00:00 2001 From: Pleasure1234 <3196812536@qq.com> Date: Fri, 31 Oct 2025 03:21:10 +0000 Subject: [PATCH 01/10] fix: sort grouped items by saved tags order from Redux (#11065) Updated useUnifiedGrouping to sort grouped items by the tags order saved in the Redux store, falling back to untagged first. This improves consistency with user-defined tag ordering. --- .../home/Tabs/hooks/useUnifiedGrouping.ts | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/pages/home/Tabs/hooks/useUnifiedGrouping.ts b/src/renderer/src/pages/home/Tabs/hooks/useUnifiedGrouping.ts index 8ea07f57e..90d2601f6 100644 --- a/src/renderer/src/pages/home/Tabs/hooks/useUnifiedGrouping.ts +++ b/src/renderer/src/pages/home/Tabs/hooks/useUnifiedGrouping.ts @@ -1,4 +1,5 @@ -import { useAppDispatch } from '@renderer/store' +import { createSelector } from '@reduxjs/toolkit' +import { RootState, useAppDispatch, useAppSelector } from '@renderer/store' import { setUnifiedListOrder } from '@renderer/store/assistants' import { AgentEntity, Assistant } from '@renderer/types' import { useCallback, useMemo } from 'react' @@ -21,6 +22,13 @@ export const useUnifiedGrouping = (options: UseUnifiedGroupingOptions) => { const { t } = useTranslation() const dispatch = useAppDispatch() + // Selector to get tagsOrder from Redux store + const selectTagsOrder = useMemo( + () => createSelector([(state: RootState) => state.assistants], (assistants) => assistants.tagsOrder ?? []), + [] + ) + const savedTagsOrder = useAppSelector(selectTagsOrder) + // Group unified items by tags const groupedUnifiedItems = useMemo(() => { const groups = new Map() @@ -45,16 +53,30 @@ export const useUnifiedGrouping = (options: UseUnifiedGroupingOptions) => { } }) - // Sort groups: untagged first, then tagged groups + // Sort groups: untagged first, then by savedTagsOrder const untaggedKey = t('assistants.tags.untagged') const sortedGroups = Array.from(groups.entries()).sort(([tagA], [tagB]) => { if (tagA === untaggedKey) return -1 if (tagB === untaggedKey) return 1 + + if (savedTagsOrder.length > 0) { + const indexA = savedTagsOrder.indexOf(tagA) + const indexB = savedTagsOrder.indexOf(tagB) + + if (indexA !== -1 && indexB !== -1) { + return indexA - indexB + } + + if (indexA !== -1) return -1 + + if (indexB !== -1) return 1 + } + return 0 }) return sortedGroups.map(([tag, items]) => ({ tag, items })) - }, [unifiedItems, t]) + }, [unifiedItems, t, savedTagsOrder]) const handleUnifiedGroupReorder = useCallback( (tag: string, newGroupList: UnifiedItem[]) => { From aa810a7ead772ba3b6a3f136a93eaac8b7e95573 Mon Sep 17 00:00:00 2001 From: defi-failure <159208748+defi-failure@users.noreply.github.com> Date: Fri, 31 Oct 2025 12:13:59 +0800 Subject: [PATCH 02/10] fix: notify renderer when api server ready (#11049) * fix: notify renderer when api server ready * chore: minor comment update Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: minor ui change to reflect server loading state --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/shared/IpcChannel.ts | 1 + src/main/apiServer/server.ts | 9 +++++++++ src/preload/index.ts | 11 ++++++++++- src/renderer/src/hooks/agents/useAgents.ts | 6 +++++- src/renderer/src/hooks/useApiServer.ts | 14 ++++++++++++-- src/renderer/src/pages/home/Tabs/AssistantsTab.tsx | 6 +++--- 6 files changed, 40 insertions(+), 7 deletions(-) diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index a8421354f..9a3866d48 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -322,6 +322,7 @@ export enum IpcChannel { ApiServer_Stop = 'api-server:stop', ApiServer_Restart = 'api-server:restart', ApiServer_GetStatus = 'api-server:get-status', + ApiServer_Ready = 'api-server:ready', // NOTE: This api is not be used. ApiServer_GetConfig = 'api-server:get-config', diff --git a/src/main/apiServer/server.ts b/src/main/apiServer/server.ts index 3cb81f412..9b15e56da 100644 --- a/src/main/apiServer/server.ts +++ b/src/main/apiServer/server.ts @@ -1,8 +1,10 @@ import { createServer } from 'node:http' import { loggerService } from '@logger' +import { IpcChannel } from '@shared/IpcChannel' import { agentService } from '../services/agents' +import { windowService } from '../services/WindowService' import { app } from './app' import { config } from './config' @@ -43,6 +45,13 @@ export class ApiServer { return new Promise((resolve, reject) => { this.server!.listen(port, host, () => { logger.info('API server started', { host, port }) + + // Notify renderer that API server is ready + const mainWindow = windowService.getMainWindow() + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(IpcChannel.ApiServer_Ready) + } + resolve() }) diff --git a/src/preload/index.ts b/src/preload/index.ts index 12aa9fd3b..fa39ac698 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -525,7 +525,16 @@ const api = { getStatus: (): Promise => ipcRenderer.invoke(IpcChannel.ApiServer_GetStatus), start: (): Promise => ipcRenderer.invoke(IpcChannel.ApiServer_Start), restart: (): Promise => ipcRenderer.invoke(IpcChannel.ApiServer_Restart), - stop: (): Promise => ipcRenderer.invoke(IpcChannel.ApiServer_Stop) + stop: (): Promise => ipcRenderer.invoke(IpcChannel.ApiServer_Stop), + onReady: (callback: () => void): (() => void) => { + const listener = () => { + callback() + } + ipcRenderer.on(IpcChannel.ApiServer_Ready, listener) + return () => { + ipcRenderer.removeListener(IpcChannel.ApiServer_Ready, listener) + } + } }, claudeCodePlugin: { listAvailable: (): Promise> => diff --git a/src/renderer/src/hooks/agents/useAgents.ts b/src/renderer/src/hooks/agents/useAgents.ts index f14b89351..3f5dcb6e3 100644 --- a/src/renderer/src/hooks/agents/useAgents.ts +++ b/src/renderer/src/hooks/agents/useAgents.ts @@ -25,6 +25,10 @@ export const useAgents = () => { const client = useAgentClient() const key = client.agentPaths.base const { apiServerConfig, apiServerRunning } = useApiServer() + + // Disable SWR fetching when server is not running by setting key to null + const swrKey = apiServerRunning ? key : null + const fetcher = useCallback(async () => { // API server will start on startup if enabled OR there are agents if (!apiServerConfig.enabled && !apiServerRunning) { @@ -37,7 +41,7 @@ export const useAgents = () => { // NOTE: We only use the array for now. useUpdateAgent depends on this behavior. return result.data }, [apiServerConfig.enabled, apiServerRunning, client, t]) - const { data, error, isLoading, mutate } = useSWR(key, fetcher) + const { data, error, isLoading, mutate } = useSWR(swrKey, fetcher) const { chat } = useRuntime() const { activeAgentId } = chat const dispatch = useAppDispatch() diff --git a/src/renderer/src/hooks/useApiServer.ts b/src/renderer/src/hooks/useApiServer.ts index ae418f6cc..38f6fa64d 100644 --- a/src/renderer/src/hooks/useApiServer.ts +++ b/src/renderer/src/hooks/useApiServer.ts @@ -14,8 +14,8 @@ export const useApiServer = () => { const apiServerConfig = useAppSelector((state) => state.settings.apiServer) const dispatch = useAppDispatch() - // Optimistic initial state. - const [apiServerRunning, setApiServerRunning] = useState(apiServerConfig.enabled) + // Initial state - no longer optimistic, wait for actual status + const [apiServerRunning, setApiServerRunning] = useState(false) const [apiServerLoading, setApiServerLoading] = useState(true) const setApiServerEnabled = useCallback( @@ -99,6 +99,16 @@ export const useApiServer = () => { checkApiServerStatus() }, [checkApiServerStatus]) + // Listen for API server ready event + useEffect(() => { + const cleanup = window.api.apiServer.onReady(() => { + logger.info('API server ready event received, checking status') + checkApiServerStatus() + }) + + return cleanup + }, [checkApiServerStatus]) + return { apiServerConfig, apiServerRunning, diff --git a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx index b0c901851..8fc439b7b 100644 --- a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx @@ -36,7 +36,7 @@ const AssistantsTab: FC = (props) => { const { activeAssistant, setActiveAssistant, onCreateAssistant, onCreateDefaultAssistant } = props const containerRef = useRef(null) const { t } = useTranslation() - const { apiServerConfig, apiServerRunning } = useApiServer() + const { apiServerConfig, apiServerRunning, apiServerLoading } = useApiServer() const apiServerEnabled = apiServerConfig.enabled const { iknow, chat } = useRuntime() const dispatch = useAppDispatch() @@ -113,8 +113,8 @@ const AssistantsTab: FC = (props) => { /> )} - {agentsLoading && } - {apiServerConfig.enabled && !apiServerRunning && ( + {(agentsLoading || apiServerLoading) && } + {apiServerConfig.enabled && !apiServerLoading && !apiServerRunning && ( )} {apiServerRunning && agentsError && ( From f8a599322f00c56f4d56b742dee1e1acccd6fd31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=A2=E5=A5=8B=E7=8C=AB?= Date: Fri, 31 Oct 2025 13:35:27 +0800 Subject: [PATCH 03/10] =?UTF-8?q?feat(useAppInit):=20implement=20automatic?= =?UTF-8?q?=20update=20checks=20with=20interval=20sup=E2=80=A6=20(#11063)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(useAppInit): implement automatic update checks with interval support - Added a function to check for updates, which is called initially and set to run every 6 hours if the app is packaged and auto-update is enabled. - Refactored the initial update check to utilize the new function for better code organization and clarity. --- src/renderer/src/hooks/useAppInit.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/hooks/useAppInit.ts b/src/renderer/src/hooks/useAppInit.ts index 28fc6a958..3ecbdf762 100644 --- a/src/renderer/src/hooks/useAppInit.ts +++ b/src/renderer/src/hooks/useAppInit.ts @@ -83,14 +83,31 @@ export function useAppInit() { }, [avatar, dispatch]) useEffect(() => { + const checkForUpdates = async () => { + const { isPackaged } = await window.api.getAppInfo() + + if (!isPackaged || !autoCheckUpdate) { + return + } + + const { updateInfo } = await window.api.checkForUpdate() + dispatch(setUpdateState({ info: updateInfo })) + } + + // Initial check with delay runAsyncFunction(async () => { const { isPackaged } = await window.api.getAppInfo() if (isPackaged && autoCheckUpdate) { await delay(2) - const { updateInfo } = await window.api.checkForUpdate() - dispatch(setUpdateState({ info: updateInfo })) + await checkForUpdates() } }) + + // Set up 4-hour interval check + const FOUR_HOURS = 4 * 60 * 60 * 1000 + const intervalId = setInterval(checkForUpdates, FOUR_HOURS) + + return () => clearInterval(intervalId) }, [dispatch, autoCheckUpdate]) useEffect(() => { From d792bf7fe07416bf0bfc7c12101a0bd08c322ee1 Mon Sep 17 00:00:00 2001 From: LiuVaayne <10231735+vaayne@users.noreply.github.com> Date: Fri, 31 Oct 2025 14:30:50 +0800 Subject: [PATCH 04/10] =?UTF-8?q?=F0=9F=90=9B=20fix:=20resolve=20tool=20ap?= =?UTF-8?q?proval=20UI=20and=20shared=20workspace=20plugin=20inconsistency?= =?UTF-8?q?=20(#11043)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ToolPermissionRequestCard): simplify button rendering by removing suggestion handling * ✨ feat: add CachedPluginsDataSchema for plugin cache file - Add Zod schema for .claude/plugins.json cache file format - Schema includes version, lastUpdated timestamp, and plugins array - Reuses existing InstalledPluginSchema for type safety - Cache will store metadata for all installed plugins * ✨ feat: add cache management methods to PluginService - Add readCacheFile() to read .claude/plugins.json - Add writeCacheFile() for atomic cache writes (temp + rename) - Add rebuildCache() to scan filesystem and rebuild cache - Add listInstalledFromCache() to load plugins from cache with fallback - Add updateCache() helper for transactional cache updates - All methods handle missing/corrupt cache gracefully - Cache auto-regenerates from filesystem if needed * ✨ feat: integrate cache loading in AgentService.getAgent() - Add installed_plugins field to GetAgentResponseSchema - Load plugins from cache via PluginService.listInstalledFromCache() - Gracefully handle errors by returning empty array - Use loggerService for error logging * 🐛 fix: break circular dependency causing infinite loop in cache methods - Change cache method signatures from agentId to workdir parameter - Update listInstalledFromCache(workdir) to accept workdir directly - Update rebuildCache(workdir) to accept workdir directly - Update updateCache(workdir, updater) to accept workdir directly - AgentService.getAgent() now passes accessible_paths[0] to cache methods - Removes AgentService.getAgent() calls from PluginService methods - Fixes infinite recursion bug where methods called each other endlessly Breaking the circular dependency: BEFORE: AgentService.getAgent() → PluginService.listInstalledFromCache(id) → AgentService.getAgent(id) [INFINITE LOOP] AFTER: AgentService.getAgent() → PluginService.listInstalledFromCache(workdir) [NO MORE RECURSION] * 🐛 fix: update listInstalled() to use agent.installed_plugins - Change from agent.configuration.installed_plugins (old DB location) - To agent.installed_plugins (new top-level field from cache) - Simplify validation logic to use existing plugin structure - Fixes UI not showing installed plugins correctly This was causing the UI to show empty plugin lists even though plugins were correctly loaded in the cache by AgentService.getAgent(). * ♻️ refactor: remove unused updateCache helper * ♻️ refactor: centralize plugin directory helpers * feat: Implement Plugin Management System - Added PluginCacheStore for managing plugin metadata and caching. - Introduced PluginInstaller for handling installation and uninstallation of plugins. - Created PluginService to manage plugin lifecycle, including installation, uninstallation, and listing of available plugins. - Enhanced AgentService to integrate with PluginService for loading installed plugins. - Implemented validation and sanitization for plugin file names and paths to prevent security issues. - Added support for skills as a new plugin type, including installation and management. - Introduced caching mechanism for available plugins to improve performance. * ♻️ refactor: simplify PluginInstaller and PluginService by removing agent dependency and updating plugin handling --- src/main/ipc.ts | 2 +- src/main/services/PluginService.ts | 1171 ----------------- .../agents/plugins/PluginCacheStore.ts | 426 ++++++ .../agents/plugins/PluginInstaller.ts | 149 +++ .../services/agents/plugins/PluginService.ts | 614 +++++++++ .../services/agents/services/AgentService.ts | 22 + .../Tools/ToolPermissionRequestCard.tsx | 44 +- src/renderer/src/types/agent.ts | 30 +- src/renderer/src/types/plugin.ts | 9 + 9 files changed, 1236 insertions(+), 1231 deletions(-) delete mode 100644 src/main/services/PluginService.ts create mode 100644 src/main/services/agents/plugins/PluginCacheStore.ts create mode 100644 src/main/services/agents/plugins/PluginInstaller.ts create mode 100644 src/main/services/agents/plugins/PluginService.ts diff --git a/src/main/ipc.ts b/src/main/ipc.ts index cf0892f93..94f8556d1 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -27,6 +27,7 @@ import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPref import fontList from 'font-list' import { agentMessageRepository } from './services/agents/database' +import { PluginService } from './services/agents/plugins/PluginService' import { apiServerService } from './services/ApiServerService' import appService from './services/AppService' import AppUpdater from './services/AppUpdater' @@ -47,7 +48,6 @@ import * as NutstoreService from './services/NutstoreService' import ObsidianVaultService from './services/ObsidianVaultService' import { ocrService } from './services/ocr/OcrService' import OvmsManager from './services/OvmsManager' -import { PluginService } from './services/PluginService' import { proxyManager } from './services/ProxyManager' import { pythonService } from './services/PythonService' import { FileServiceManager } from './services/remotefile/FileServiceManager' diff --git a/src/main/services/PluginService.ts b/src/main/services/PluginService.ts deleted file mode 100644 index 8ff182020..000000000 --- a/src/main/services/PluginService.ts +++ /dev/null @@ -1,1171 +0,0 @@ -import { loggerService } from '@logger' -import { copyDirectoryRecursive, deleteDirectoryRecursive } from '@main/utils/fileOperations' -import { findAllSkillDirectories, parsePluginMetadata, parseSkillMetadata } from '@main/utils/markdownParser' -import type { - AgentEntity, - InstalledPlugin, - InstallPluginOptions, - ListAvailablePluginsResult, - PluginError, - PluginMetadata, - PluginType, - UninstallPluginOptions -} from '@types' -import * as crypto from 'crypto' -import { app } from 'electron' -import * as fs from 'fs' -import * as path from 'path' - -import { AgentService } from './agents/services/AgentService' - -const logger = loggerService.withContext('PluginService') - -interface PluginServiceConfig { - maxFileSize: number // bytes - cacheTimeout: number // milliseconds -} - -/** - * PluginService manages agent and command plugins from resources directory. - * - * Features: - * - Singleton pattern for consistent state management - * - Caching of available plugins for performance - * - Security validation (path traversal, file size, extensions) - * - Transactional install/uninstall operations - * - Integration with AgentService for metadata persistence - */ -export class PluginService { - private static instance: PluginService | null = null - - private availablePluginsCache: ListAvailablePluginsResult | null = null - private cacheTimestamp = 0 - private config: PluginServiceConfig - - private readonly ALLOWED_EXTENSIONS = ['.md', '.markdown'] - - private constructor(config?: Partial) { - this.config = { - maxFileSize: config?.maxFileSize ?? 1024 * 1024, // 1MB default - cacheTimeout: config?.cacheTimeout ?? 5 * 60 * 1000 // 5 minutes default - } - - logger.info('PluginService initialized', { - maxFileSize: this.config.maxFileSize, - cacheTimeout: this.config.cacheTimeout - }) - } - - /** - * Get singleton instance - */ - static getInstance(config?: Partial): PluginService { - if (!PluginService.instance) { - PluginService.instance = new PluginService(config) - } - return PluginService.instance - } - - /** - * List all available plugins from resources directory (with caching) - */ - async listAvailable(): Promise { - const now = Date.now() - - // Return cached data if still valid - if (this.availablePluginsCache && now - this.cacheTimestamp < this.config.cacheTimeout) { - logger.debug('Returning cached plugin list', { - cacheAge: now - this.cacheTimestamp - }) - return this.availablePluginsCache - } - - logger.info('Scanning available plugins') - - // Scan all plugin types - const [agents, commands, skills] = await Promise.all([ - this.scanPluginDirectory('agent'), - this.scanPluginDirectory('command'), - this.scanSkillDirectory() - ]) - - const result: ListAvailablePluginsResult = { - agents, - commands, - skills, // NEW: include skills - total: agents.length + commands.length + skills.length - } - - // Update cache - this.availablePluginsCache = result - this.cacheTimestamp = now - - logger.info('Available plugins scanned', { - agentsCount: agents.length, - commandsCount: commands.length, - skillsCount: skills.length, - total: result.total - }) - - return result - } - - /** - * Install plugin with validation and transactional safety - */ - async install(options: InstallPluginOptions): Promise { - logger.info('Installing plugin', options) - - // Validate source path - this.validateSourcePath(options.sourcePath) - - // Get agent and validate - const agent = await AgentService.getInstance().getAgent(options.agentId) - if (!agent) { - throw { - type: 'INVALID_WORKDIR', - agentId: options.agentId, - workdir: '', - message: 'Agent not found' - } as PluginError - } - - const workdir = agent.accessible_paths?.[0] - if (!workdir) { - throw { - type: 'INVALID_WORKDIR', - agentId: options.agentId, - workdir: '', - message: 'Agent has no accessible paths' - } as PluginError - } - - await this.validateWorkdir(workdir, options.agentId) - - // Get absolute source path - const basePath = this.getPluginsBasePath() - const sourceAbsolutePath = path.join(basePath, options.sourcePath) - - // BRANCH: Handle skills differently than files - if (options.type === 'skill') { - // Validate skill folder exists and is a directory - try { - const stats = await fs.promises.stat(sourceAbsolutePath) - if (!stats.isDirectory()) { - throw { - type: 'INVALID_METADATA', - reason: 'Skill source is not a directory', - path: options.sourcePath - } as PluginError - } - } catch (error) { - throw { - type: 'FILE_NOT_FOUND', - path: sourceAbsolutePath - } as PluginError - } - - // Parse metadata from SKILL.md - const metadata = await parseSkillMetadata(sourceAbsolutePath, options.sourcePath, 'skills') - - // Sanitize folder name (different rules than file names) - const sanitizedFolderName = this.sanitizeFolderName(metadata.filename) - - // Ensure .claude/skills directory exists - await this.ensureClaudeDirectory(workdir, 'skill') - - // Construct destination path (folder, not file) - const destPath = path.join(workdir, '.claude', 'skills', sanitizedFolderName) - - // Update metadata with sanitized folder name - metadata.filename = sanitizedFolderName - - // Execute skill-specific install - await this.installSkill(agent, sourceAbsolutePath, destPath, metadata) - - logger.info('Skill installed successfully', { - agentId: options.agentId, - sourcePath: options.sourcePath, - folderName: sanitizedFolderName - }) - - return { - ...metadata, - installedAt: Date.now() - } - } - - // EXISTING LOGIC for agents/commands (unchanged) - // Files go through existing validation and sanitization - await this.validatePluginFile(sourceAbsolutePath) - - // Parse metadata - const category = path.basename(path.dirname(options.sourcePath)) - const metadata = await parsePluginMetadata(sourceAbsolutePath, options.sourcePath, category, options.type) - - // Sanitize filename - const sanitizedFilename = this.sanitizeFilename(metadata.filename) - - // Ensure .claude directory exists - await this.ensureClaudeDirectory(workdir, options.type) - - // Get destination path - const destDir = path.join(workdir, '.claude', options.type === 'agent' ? 'agents' : 'commands') - const destPath = path.join(destDir, sanitizedFilename) - - // Check for duplicate and auto-uninstall if exists - const existingPlugins = agent.configuration?.installed_plugins || [] - const existingPlugin = existingPlugins.find((p) => p.filename === sanitizedFilename && p.type === options.type) - - if (existingPlugin) { - logger.info('Plugin already installed, auto-uninstalling old version', { - filename: sanitizedFilename - }) - await this.uninstallTransaction(agent, sanitizedFilename, options.type) - - // Re-fetch agent after uninstall - const updatedAgent = await AgentService.getInstance().getAgent(options.agentId) - if (!updatedAgent) { - throw { - type: 'TRANSACTION_FAILED', - operation: 'install', - reason: 'Agent not found after uninstall' - } as PluginError - } - - await this.installTransaction(updatedAgent, sourceAbsolutePath, destPath, metadata) - } else { - await this.installTransaction(agent, sourceAbsolutePath, destPath, metadata) - } - - logger.info('Plugin installed successfully', { - agentId: options.agentId, - filename: sanitizedFilename, - type: options.type - }) - - return { - ...metadata, - filename: sanitizedFilename, - installedAt: Date.now() - } - } - - /** - * Uninstall plugin with cleanup - */ - async uninstall(options: UninstallPluginOptions): Promise { - logger.info('Uninstalling plugin', options) - - // Get agent - const agent = await AgentService.getInstance().getAgent(options.agentId) - if (!agent) { - throw { - type: 'INVALID_WORKDIR', - agentId: options.agentId, - workdir: '', - message: 'Agent not found' - } as PluginError - } - - // BRANCH: Handle skills differently than files - if (options.type === 'skill') { - // For skills, filename is the folder name (no extension) - // Use sanitizeFolderName to ensure consistency - const sanitizedFolderName = this.sanitizeFolderName(options.filename) - await this.uninstallSkill(agent, sanitizedFolderName) - - logger.info('Skill uninstalled successfully', { - agentId: options.agentId, - folderName: sanitizedFolderName - }) - - return - } - - // EXISTING LOGIC for agents/commands (unchanged) - // For files, filename includes .md extension - const sanitizedFilename = this.sanitizeFilename(options.filename) - await this.uninstallTransaction(agent, sanitizedFilename, options.type) - - logger.info('Plugin uninstalled successfully', { - agentId: options.agentId, - filename: sanitizedFilename, - type: options.type - }) - } - - /** - * List installed plugins for an agent (from database + filesystem validation) - */ - async listInstalled(agentId: string): Promise { - logger.debug('Listing installed plugins', { agentId }) - - // Get agent - const agent = await AgentService.getInstance().getAgent(agentId) - if (!agent) { - throw { - type: 'INVALID_WORKDIR', - agentId, - workdir: '', - message: 'Agent not found' - } as PluginError - } - - const installedPlugins = agent.configuration?.installed_plugins || [] - const workdir = agent.accessible_paths?.[0] - - if (!workdir) { - logger.warn('Agent has no accessible paths', { agentId }) - return [] - } - - // Validate each plugin still exists on filesystem - const validatedPlugins: InstalledPlugin[] = [] - - for (const plugin of installedPlugins) { - // Get plugin path based on type - let pluginPath: string - if (plugin.type === 'skill') { - pluginPath = path.join(workdir, '.claude', 'skills', plugin.filename) - } else { - pluginPath = path.join(workdir, '.claude', plugin.type === 'agent' ? 'agents' : 'commands', plugin.filename) - } - - try { - const stats = await fs.promises.stat(pluginPath) - - // For files (agents/commands), verify file hash if stored - if (plugin.type !== 'skill' && plugin.contentHash) { - const currentHash = await this.calculateFileHash(pluginPath) - if (currentHash !== plugin.contentHash) { - logger.warn('Plugin file hash mismatch', { - filename: plugin.filename, - expected: plugin.contentHash, - actual: currentHash - }) - } - } - - // For skills, stats.size is folder size (handled differently) - // For files, stats.size is file size - validatedPlugins.push({ - filename: plugin.filename, - type: plugin.type, - metadata: { - sourcePath: plugin.sourcePath, - filename: plugin.filename, - name: plugin.name, - description: plugin.description, - allowed_tools: plugin.allowed_tools, - tools: plugin.tools, - category: plugin.category || '', - type: plugin.type, - tags: plugin.tags, - version: plugin.version, - author: plugin.author, - size: stats.size, - contentHash: plugin.contentHash, - installedAt: plugin.installedAt, - updatedAt: plugin.updatedAt - } - }) - } catch (error) { - logger.warn('Plugin not found on filesystem', { - filename: plugin.filename, - path: pluginPath, - error: error instanceof Error ? error.message : String(error) - }) - } - } - - logger.debug('Listed installed plugins', { - agentId, - count: validatedPlugins.length - }) - - return validatedPlugins - } - - /** - * Invalidate plugin cache (for development/testing) - */ - invalidateCache(): void { - this.availablePluginsCache = null - this.cacheTimestamp = 0 - logger.info('Plugin cache invalidated') - } - - /** - * Read plugin content from source (resources directory) - */ - async readContent(sourcePath: string): Promise { - logger.info('Reading plugin content', { sourcePath }) - - // Validate source path - this.validateSourcePath(sourcePath) - - // Get absolute path - const basePath = this.getPluginsBasePath() - const absolutePath = path.join(basePath, sourcePath) - - // Validate file exists and is accessible - try { - await fs.promises.access(absolutePath, fs.constants.R_OK) - } catch (error) { - throw { - type: 'FILE_NOT_FOUND', - path: sourcePath - } as PluginError - } - - // Read content - try { - const content = await fs.promises.readFile(absolutePath, 'utf8') - logger.debug('Plugin content read successfully', { - sourcePath, - size: content.length - }) - return content - } catch (error) { - throw { - type: 'READ_FAILED', - path: sourcePath, - reason: error instanceof Error ? error.message : String(error) - } as PluginError - } - } - - /** - * Write plugin content to installed plugin (in agent's .claude directory) - * Note: Only works for file-based plugins (agents/commands), not skills - */ - async writeContent(agentId: string, filename: string, type: PluginType, content: string): Promise { - logger.info('Writing plugin content', { agentId, filename, type }) - - // Get agent - const agent = await AgentService.getInstance().getAgent(agentId) - if (!agent) { - throw { - type: 'INVALID_WORKDIR', - agentId, - workdir: '', - message: 'Agent not found' - } as PluginError - } - - const workdir = agent.accessible_paths?.[0] - if (!workdir) { - throw { - type: 'INVALID_WORKDIR', - agentId, - workdir: '', - message: 'Agent has no accessible paths' - } as PluginError - } - - // Check if plugin is installed - const installedPlugins = agent.configuration?.installed_plugins || [] - const installedPlugin = installedPlugins.find((p) => p.filename === filename && p.type === type) - - if (!installedPlugin) { - throw { - type: 'PLUGIN_NOT_INSTALLED', - filename, - agentId - } as PluginError - } - - // Get file path - const filePath = path.join(workdir, '.claude', type === 'agent' ? 'agents' : 'commands', filename) - - // Verify file exists - try { - await fs.promises.access(filePath, fs.constants.W_OK) - } catch (error) { - throw { - type: 'FILE_NOT_FOUND', - path: filePath - } as PluginError - } - - // Write content - try { - await fs.promises.writeFile(filePath, content, 'utf8') - logger.debug('Plugin content written successfully', { - filePath, - size: content.length - }) - - // Update content hash in database - const newContentHash = crypto.createHash('sha256').update(content).digest('hex') - const updatedPlugins = installedPlugins.map((p) => { - if (p.filename === filename && p.type === type) { - return { - ...p, - contentHash: newContentHash, - updatedAt: Date.now() - } - } - return p - }) - - await AgentService.getInstance().updateAgent(agentId, { - configuration: { - permission_mode: 'default', - max_turns: 100, - ...agent.configuration, - installed_plugins: updatedPlugins - } - }) - - logger.info('Plugin content updated successfully', { - agentId, - filename, - type, - newContentHash - }) - } catch (error) { - throw { - type: 'WRITE_FAILED', - path: filePath, - reason: error instanceof Error ? error.message : String(error) - } as PluginError - } - } - - // ============================================================================ - // Private Helper Methods - // ============================================================================ - - /** - * Get absolute path to plugins directory (handles packaged vs dev) - */ - private getPluginsBasePath(): string { - // Use the utility function which handles both dev and production correctly - if (app.isPackaged) { - return path.join(process.resourcesPath, 'claude-code-plugins') - } - return path.join(__dirname, '../../node_modules/claude-code-plugins/plugins') - } - - /** - * Scan plugin directory and return metadata for all plugins - */ - private async scanPluginDirectory(type: 'agent' | 'command'): Promise { - const basePath = this.getPluginsBasePath() - const typeDir = path.join(basePath, type === 'agent' ? 'agents' : 'commands') - - try { - await fs.promises.access(typeDir, fs.constants.R_OK) - } catch (error) { - logger.warn(`Plugin directory not accessible: ${typeDir}`, { - error: error instanceof Error ? error.message : String(error) - }) - return [] - } - - const plugins: PluginMetadata[] = [] - const categories = await fs.promises.readdir(typeDir, { withFileTypes: true }) - - for (const categoryEntry of categories) { - if (!categoryEntry.isDirectory()) { - continue - } - - const category = categoryEntry.name - const categoryPath = path.join(typeDir, category) - const files = await fs.promises.readdir(categoryPath, { withFileTypes: true }) - - for (const file of files) { - if (!file.isFile()) { - continue - } - - const ext = path.extname(file.name).toLowerCase() - if (!this.ALLOWED_EXTENSIONS.includes(ext)) { - continue - } - - try { - const filePath = path.join(categoryPath, file.name) - const sourcePath = path.join(type === 'agent' ? 'agents' : 'commands', category, file.name) - - const metadata = await parsePluginMetadata(filePath, sourcePath, category, type) - plugins.push(metadata) - } catch (error) { - logger.warn(`Failed to parse plugin: ${file.name}`, { - category, - error: error instanceof Error ? error.message : String(error) - }) - } - } - } - - return plugins - } - - /** - * Scan skills directory for skill folders (recursively) - */ - private async scanSkillDirectory(): Promise { - const basePath = this.getPluginsBasePath() - const skillsPath = path.join(basePath, 'skills') - - const skills: PluginMetadata[] = [] - - try { - // Check if skills directory exists - try { - await fs.promises.access(skillsPath) - } catch { - logger.warn('Skills directory not found', { skillsPath }) - return [] - } - - // Recursively find all directories containing SKILL.md - const skillDirectories = await findAllSkillDirectories(skillsPath, basePath) - - logger.info(`Found ${skillDirectories.length} skill directories`, { skillsPath }) - - // Parse metadata for each skill directory - for (const { folderPath, sourcePath } of skillDirectories) { - try { - const metadata = await parseSkillMetadata(folderPath, sourcePath, 'skills') - skills.push(metadata) - } catch (error) { - logger.warn(`Failed to parse skill folder: ${sourcePath}`, { - folderPath, - error: error instanceof Error ? error.message : String(error) - }) - // Continue with other skills - } - } - } catch (error) { - logger.error('Failed to scan skill directory', { skillsPath, error }) - // Return empty array on error - } - - return skills - } - - /** - * Validate source path to prevent path traversal attacks - */ - private validateSourcePath(sourcePath: string): void { - // Remove any path traversal attempts - const normalized = path.normalize(sourcePath) - - // Ensure no parent directory access - if (normalized.includes('..')) { - throw { - type: 'PATH_TRAVERSAL', - message: 'Path traversal detected', - path: sourcePath - } as PluginError - } - - // Ensure path is within plugins directory - const basePath = this.getPluginsBasePath() - const absolutePath = path.join(basePath, normalized) - const resolvedPath = path.resolve(absolutePath) - - if (!resolvedPath.startsWith(path.resolve(basePath))) { - throw { - type: 'PATH_TRAVERSAL', - message: 'Path outside plugins directory', - path: sourcePath - } as PluginError - } - } - - /** - * Validate workdir against agent's accessible paths - */ - private async validateWorkdir(workdir: string, agentId: string): Promise { - // Get agent from database - const agent = await AgentService.getInstance().getAgent(agentId) - - if (!agent) { - throw { - type: 'INVALID_WORKDIR', - workdir, - agentId, - message: 'Agent not found' - } as PluginError - } - - // Verify workdir is in agent's accessible_paths - if (!agent.accessible_paths?.includes(workdir)) { - throw { - type: 'INVALID_WORKDIR', - workdir, - agentId, - message: 'Workdir not in agent accessible paths' - } as PluginError - } - - // Verify workdir exists and is accessible - try { - await fs.promises.access(workdir, fs.constants.R_OK | fs.constants.W_OK) - } catch (error) { - throw { - type: 'WORKDIR_NOT_FOUND', - workdir, - message: 'Workdir does not exist or is not accessible' - } as PluginError - } - } - - /** - * Sanitize filename to remove unsafe characters (for agents/commands) - */ - private sanitizeFilename(filename: string): string { - // Remove path separators - let sanitized = filename.replace(/[/\\]/g, '_') - // Remove null bytes using String method to avoid control-regex lint error - sanitized = sanitized.replace(new RegExp(String.fromCharCode(0), 'g'), '') - // Limit to safe characters (alphanumeric, dash, underscore, dot) - sanitized = sanitized.replace(/[^a-zA-Z0-9._-]/g, '_') - - // Ensure .md extension - if (!sanitized.endsWith('.md') && !sanitized.endsWith('.markdown')) { - sanitized += '.md' - } - - return sanitized - } - - /** - * Sanitize folder name for skills (different rules than file names) - * NO dots allowed to avoid confusion with file extensions - */ - private sanitizeFolderName(folderName: string): string { - // Remove path separators - let sanitized = folderName.replace(/[/\\]/g, '_') - // Remove null bytes using String method to avoid control-regex lint error - sanitized = sanitized.replace(new RegExp(String.fromCharCode(0), 'g'), '') - // Limit to safe characters (alphanumeric, dash, underscore) - // NOTE: No dots allowed to avoid confusion with file extensions - sanitized = sanitized.replace(/[^a-zA-Z0-9_-]/g, '_') - - // Validate no extension was provided - if (folderName.includes('.')) { - logger.warn('Skill folder name contained dots, sanitized', { - original: folderName, - sanitized - }) - } - - return sanitized - } - - /** - * Validate plugin file (size, extension, frontmatter) - */ - private async validatePluginFile(filePath: string): Promise { - // Check file exists - let stats: fs.Stats - try { - stats = await fs.promises.stat(filePath) - } catch (error) { - throw { - type: 'FILE_NOT_FOUND', - path: filePath - } as PluginError - } - - // Check file size - if (stats.size > this.config.maxFileSize) { - throw { - type: 'FILE_TOO_LARGE', - size: stats.size, - max: this.config.maxFileSize - } as PluginError - } - - // Check file extension - const ext = path.extname(filePath).toLowerCase() - if (!this.ALLOWED_EXTENSIONS.includes(ext)) { - throw { - type: 'INVALID_FILE_TYPE', - extension: ext - } as PluginError - } - - // Validate frontmatter can be parsed safely - // This is handled by parsePluginMetadata which uses FAILSAFE_SCHEMA - try { - const category = path.basename(path.dirname(filePath)) - const sourcePath = path.relative(this.getPluginsBasePath(), filePath) - const type = sourcePath.startsWith('agents') ? 'agent' : 'command' - - await parsePluginMetadata(filePath, sourcePath, category, type) - } catch (error) { - throw { - type: 'INVALID_METADATA', - reason: 'Failed to parse frontmatter', - path: filePath - } as PluginError - } - } - - /** - * Calculate SHA-256 hash of file - */ - private async calculateFileHash(filePath: string): Promise { - const content = await fs.promises.readFile(filePath, 'utf8') - return crypto.createHash('sha256').update(content).digest('hex') - } - - /** - * Ensure .claude subdirectory exists for the given plugin type - */ - private async ensureClaudeDirectory(workdir: string, type: PluginType): Promise { - const claudeDir = path.join(workdir, '.claude') - - let subDir: string - if (type === 'agent') { - subDir = 'agents' - } else if (type === 'command') { - subDir = 'commands' - } else if (type === 'skill') { - subDir = 'skills' - } else { - throw new Error(`Unknown plugin type: ${type}`) - } - - const typeDir = path.join(claudeDir, subDir) - - try { - await fs.promises.mkdir(typeDir, { recursive: true }) - logger.debug('Ensured directory exists', { typeDir }) - } catch (error) { - logger.error('Failed to create directory', { - typeDir, - error: error instanceof Error ? error.message : String(error) - }) - throw { - type: 'PERMISSION_DENIED', - path: typeDir - } as PluginError - } - } - - /** - * Transactional install operation - * Steps: - * 1. Copy to temp location - * 2. Update database - * 3. Move to final location (atomic) - * Rollback on error - */ - private async installTransaction( - agent: AgentEntity, - sourceAbsolutePath: string, - destPath: string, - metadata: PluginMetadata - ): Promise { - const tempPath = `${destPath}.tmp` - let fileCopied = false - - try { - // Step 1: Copy file to temporary location - await fs.promises.copyFile(sourceAbsolutePath, tempPath) - fileCopied = true - logger.debug('File copied to temp location', { tempPath }) - - // Step 2: Update agent configuration in database - const existingPlugins = agent.configuration?.installed_plugins || [] - const updatedPlugins = [ - ...existingPlugins, - { - sourcePath: metadata.sourcePath, - filename: metadata.filename, - type: metadata.type, - name: metadata.name, - description: metadata.description, - allowed_tools: metadata.allowed_tools, - tools: metadata.tools, - category: metadata.category, - tags: metadata.tags, - version: metadata.version, - author: metadata.author, - contentHash: metadata.contentHash, - installedAt: Date.now() - } - ] - - await AgentService.getInstance().updateAgent(agent.id, { - configuration: { - permission_mode: 'default', - max_turns: 100, - ...agent.configuration, - installed_plugins: updatedPlugins - } - }) - - logger.debug('Agent configuration updated', { agentId: agent.id }) - - // Step 3: Move temp file to final location (atomic on same filesystem) - await fs.promises.rename(tempPath, destPath) - logger.debug('File moved to final location', { destPath }) - } catch (error) { - // Rollback: delete temp file if it exists - if (fileCopied) { - try { - await fs.promises.unlink(tempPath) - logger.debug('Rolled back temp file', { tempPath }) - } catch (unlinkError) { - logger.error('Failed to rollback temp file', { - tempPath, - error: unlinkError instanceof Error ? unlinkError.message : String(unlinkError) - }) - } - } - - throw { - type: 'TRANSACTION_FAILED', - operation: 'install', - reason: error instanceof Error ? error.message : String(error) - } as PluginError - } - } - - /** - * Transactional uninstall operation - * Steps: - * 1. Update database - * 2. Delete file - * Rollback database on error - */ - private async uninstallTransaction(agent: AgentEntity, filename: string, type: 'agent' | 'command'): Promise { - const workdir = agent.accessible_paths?.[0] - if (!workdir) { - throw { - type: 'INVALID_WORKDIR', - agentId: agent.id, - workdir: '', - message: 'Agent has no accessible paths' - } as PluginError - } - - const filePath = path.join(workdir, '.claude', type === 'agent' ? 'agents' : 'commands', filename) - - // Step 1: Update database first (easier to rollback file operations) - const originalPlugins = agent.configuration?.installed_plugins || [] - const updatedPlugins = originalPlugins.filter((p) => !(p.filename === filename && p.type === type)) - - let dbUpdated = false - - try { - await AgentService.getInstance().updateAgent(agent.id, { - configuration: { - permission_mode: 'default', - max_turns: 100, - ...agent.configuration, - installed_plugins: updatedPlugins - } - }) - dbUpdated = true - logger.debug('Agent configuration updated', { agentId: agent.id }) - - // Step 2: Delete file - try { - await fs.promises.unlink(filePath) - logger.debug('Plugin file deleted', { filePath }) - } catch (error) { - const nodeError = error as NodeJS.ErrnoException - if (nodeError.code !== 'ENOENT') { - throw error // File should exist, re-throw if not ENOENT - } - logger.warn('Plugin file already deleted', { filePath }) - } - } catch (error) { - // Rollback: restore database if file deletion failed - if (dbUpdated) { - try { - await AgentService.getInstance().updateAgent(agent.id, { - configuration: { - permission_mode: 'default', - max_turns: 100, - ...agent.configuration, - installed_plugins: originalPlugins - } - }) - logger.debug('Rolled back database update', { agentId: agent.id }) - } catch (rollbackError) { - logger.error('Failed to rollback database', { - agentId: agent.id, - error: rollbackError instanceof Error ? rollbackError.message : String(rollbackError) - }) - } - } - - throw { - type: 'TRANSACTION_FAILED', - operation: 'uninstall', - reason: error instanceof Error ? error.message : String(error) - } as PluginError - } - } - - /** - * Install a skill (copy entire folder) - */ - private async installSkill( - agent: AgentEntity, - sourceAbsolutePath: string, - destPath: string, - metadata: PluginMetadata - ): Promise { - const logContext = logger.withContext('installSkill') - - // Step 1: If destination exists, remove it first (overwrite behavior) - try { - await fs.promises.access(destPath) - // Exists - remove it - await deleteDirectoryRecursive(destPath) - logContext.info('Removed existing skill folder', { destPath }) - } catch { - // Doesn't exist - nothing to remove - } - - // Step 2: Copy folder to temporary location - const tempPath = `${destPath}.tmp` - let folderCopied = false - - try { - // Copy to temp location - await copyDirectoryRecursive(sourceAbsolutePath, tempPath) - folderCopied = true - logContext.info('Skill folder copied to temp location', { tempPath }) - - // Step 3: Update agent configuration in database - const updatedPlugins = [ - ...(agent.configuration?.installed_plugins || []).filter( - (p) => !(p.filename === metadata.filename && p.type === 'skill') - ), - { - sourcePath: metadata.sourcePath, - filename: metadata.filename, // Folder name, no extension - type: metadata.type, - name: metadata.name, - description: metadata.description, - tools: metadata.tools, - category: metadata.category, - tags: metadata.tags, - version: metadata.version, - author: metadata.author, - contentHash: metadata.contentHash, - installedAt: Date.now() - } - ] - - await AgentService.getInstance().updateAgent(agent.id, { - configuration: { - permission_mode: 'default', - max_turns: 100, - ...agent.configuration, - installed_plugins: updatedPlugins - } - }) - - logContext.info('Agent configuration updated', { agentId: agent.id }) - - // Step 4: Move temp folder to final location (atomic on same filesystem) - await fs.promises.rename(tempPath, destPath) - logContext.info('Skill folder moved to final location', { destPath }) - } catch (error) { - // Rollback: delete temp folder if it exists - if (folderCopied) { - try { - await deleteDirectoryRecursive(tempPath) - logContext.info('Rolled back temp folder', { tempPath }) - } catch (unlinkError) { - logContext.error('Failed to rollback temp folder', { tempPath, error: unlinkError }) - } - } - - throw { - type: 'TRANSACTION_FAILED', - operation: 'install-skill', - reason: error instanceof Error ? error.message : String(error) - } as PluginError - } - } - - /** - * Uninstall a skill (remove entire folder) - */ - private async uninstallSkill(agent: AgentEntity, folderName: string): Promise { - const logContext = logger.withContext('uninstallSkill') - const workdir = agent.accessible_paths?.[0] - - if (!workdir) { - throw { - type: 'INVALID_WORKDIR', - agentId: agent.id, - workdir: '', - message: 'Agent has no accessible paths' - } as PluginError - } - - const skillPath = path.join(workdir, '.claude', 'skills', folderName) - - // Step 1: Update database first - const originalPlugins = agent.configuration?.installed_plugins || [] - const updatedPlugins = originalPlugins.filter((p) => !(p.filename === folderName && p.type === 'skill')) - - let dbUpdated = false - - try { - await AgentService.getInstance().updateAgent(agent.id, { - configuration: { - permission_mode: 'default', - max_turns: 100, - ...agent.configuration, - installed_plugins: updatedPlugins - } - }) - dbUpdated = true - logContext.info('Agent configuration updated', { agentId: agent.id }) - - // Step 2: Delete folder - try { - await deleteDirectoryRecursive(skillPath) - logContext.info('Skill folder deleted', { skillPath }) - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error // Folder should exist, re-throw if not ENOENT - } - logContext.warn('Skill folder already deleted', { skillPath }) - } - } catch (error) { - // Rollback: restore database if folder deletion failed - if (dbUpdated) { - try { - await AgentService.getInstance().updateAgent(agent.id, { - configuration: { - permission_mode: 'default', - max_turns: 100, - ...agent.configuration, - installed_plugins: originalPlugins - } - }) - logContext.info('Rolled back database update', { agentId: agent.id }) - } catch (rollbackError) { - logContext.error('Failed to rollback database', { agentId: agent.id, error: rollbackError }) - } - } - - throw { - type: 'TRANSACTION_FAILED', - operation: 'uninstall-skill', - reason: error instanceof Error ? error.message : String(error) - } as PluginError - } - } -} - -export const pluginService = PluginService.getInstance() diff --git a/src/main/services/agents/plugins/PluginCacheStore.ts b/src/main/services/agents/plugins/PluginCacheStore.ts new file mode 100644 index 000000000..77b427f4e --- /dev/null +++ b/src/main/services/agents/plugins/PluginCacheStore.ts @@ -0,0 +1,426 @@ +import { loggerService } from '@logger' +import { findAllSkillDirectories, parsePluginMetadata, parseSkillMetadata } from '@main/utils/markdownParser' +import type { CachedPluginsData, InstalledPlugin, PluginError, PluginMetadata, PluginType } from '@types' +import { CachedPluginsDataSchema } from '@types' +import * as fs from 'fs' +import * as path from 'path' + +const logger = loggerService.withContext('PluginCacheStore') + +interface PluginCacheStoreDeps { + allowedExtensions: string[] + getPluginDirectoryName: (type: PluginType) => 'agents' | 'commands' | 'skills' + getClaudeBasePath: (workdir: string) => string + getClaudePluginDirectory: (workdir: string, type: PluginType) => string + getPluginsBasePath: () => string +} + +export class PluginCacheStore { + constructor(private readonly deps: PluginCacheStoreDeps) {} + + async listAvailableFilePlugins(type: 'agent' | 'command'): Promise { + const basePath = this.deps.getPluginsBasePath() + const directory = path.join(basePath, this.deps.getPluginDirectoryName(type)) + + try { + await fs.promises.access(directory, fs.constants.R_OK) + } catch (error) { + logger.warn(`Plugin directory not accessible: ${directory}`, { + error: error instanceof Error ? error.message : String(error) + }) + return [] + } + + const plugins: PluginMetadata[] = [] + const categories = await fs.promises.readdir(directory, { withFileTypes: true }) + + for (const categoryEntry of categories) { + if (!categoryEntry.isDirectory()) { + continue + } + + const category = categoryEntry.name + const categoryPath = path.join(directory, category) + const files = await fs.promises.readdir(categoryPath, { withFileTypes: true }) + + for (const file of files) { + if (!file.isFile()) { + continue + } + + const ext = path.extname(file.name).toLowerCase() + if (!this.deps.allowedExtensions.includes(ext)) { + continue + } + + try { + const filePath = path.join(categoryPath, file.name) + const sourcePath = path.join(this.deps.getPluginDirectoryName(type), category, file.name) + const metadata = await parsePluginMetadata(filePath, sourcePath, category, type) + plugins.push(metadata) + } catch (error) { + logger.warn(`Failed to parse plugin: ${file.name}`, { + category, + error: error instanceof Error ? error.message : String(error) + }) + } + } + } + + return plugins + } + + async listAvailableSkills(): Promise { + const basePath = this.deps.getPluginsBasePath() + const skillsPath = path.join(basePath, this.deps.getPluginDirectoryName('skill')) + const skills: PluginMetadata[] = [] + + try { + await fs.promises.access(skillsPath) + } catch { + logger.warn('Skills directory not found', { skillsPath }) + return [] + } + + try { + const skillDirectories = await findAllSkillDirectories(skillsPath, basePath) + logger.info(`Found ${skillDirectories.length} skill directories`, { skillsPath }) + + for (const { folderPath, sourcePath } of skillDirectories) { + try { + const metadata = await parseSkillMetadata(folderPath, sourcePath, 'skills') + skills.push(metadata) + } catch (error) { + logger.warn(`Failed to parse skill folder: ${sourcePath}`, { + folderPath, + error: error instanceof Error ? error.message : String(error) + }) + } + } + } catch (error) { + logger.error('Failed to scan skill directory', { + skillsPath, + error: error instanceof Error ? error.message : String(error) + }) + } + + return skills + } + + async readSourceContent(sourcePath: string): Promise { + const absolutePath = this.resolveSourcePath(sourcePath) + + try { + await fs.promises.access(absolutePath, fs.constants.R_OK) + } catch { + throw { + type: 'FILE_NOT_FOUND', + path: sourcePath + } as PluginError + } + + try { + return await fs.promises.readFile(absolutePath, 'utf-8') + } catch (error) { + throw { + type: 'READ_FAILED', + path: sourcePath, + reason: error instanceof Error ? error.message : String(error) + } as PluginError + } + } + + resolveSourcePath(sourcePath: string): string { + const normalized = path.normalize(sourcePath) + + if (normalized.includes('..')) { + throw { + type: 'PATH_TRAVERSAL', + message: 'Path traversal detected', + path: sourcePath + } as PluginError + } + + const basePath = this.deps.getPluginsBasePath() + const absolutePath = path.join(basePath, normalized) + const resolvedPath = path.resolve(absolutePath) + + if (!resolvedPath.startsWith(path.resolve(basePath))) { + throw { + type: 'PATH_TRAVERSAL', + message: 'Path outside plugins directory', + path: sourcePath + } as PluginError + } + + return resolvedPath + } + + async ensureSkillSourceDirectory(sourceAbsolutePath: string, sourcePath: string): Promise { + let stats: fs.Stats + try { + stats = await fs.promises.stat(sourceAbsolutePath) + } catch { + throw { + type: 'FILE_NOT_FOUND', + path: sourceAbsolutePath + } as PluginError + } + + if (!stats.isDirectory()) { + throw { + type: 'INVALID_METADATA', + reason: 'Skill source is not a directory', + path: sourcePath + } as PluginError + } + } + + async validatePluginFile(filePath: string, maxFileSize: number): Promise { + let stats: fs.Stats + try { + stats = await fs.promises.stat(filePath) + } catch { + throw { + type: 'FILE_NOT_FOUND', + path: filePath + } as PluginError + } + + if (stats.size > maxFileSize) { + throw { + type: 'FILE_TOO_LARGE', + size: stats.size, + max: maxFileSize + } as PluginError + } + + const ext = path.extname(filePath).toLowerCase() + if (!this.deps.allowedExtensions.includes(ext)) { + throw { + type: 'INVALID_FILE_TYPE', + extension: ext + } as PluginError + } + + try { + const basePath = this.deps.getPluginsBasePath() + const relativeSourcePath = path.relative(basePath, filePath) + const segments = relativeSourcePath.split(path.sep) + const rootDir = segments[0] + const agentDir = this.deps.getPluginDirectoryName('agent') + const type: 'agent' | 'command' = rootDir === agentDir ? 'agent' : 'command' + const category = path.basename(path.dirname(filePath)) + + await parsePluginMetadata(filePath, relativeSourcePath, category, type) + } catch (error) { + throw { + type: 'INVALID_METADATA', + reason: 'Failed to parse frontmatter', + path: filePath + } as PluginError + } + } + + async listInstalled(workdir: string): Promise { + const claudePath = this.deps.getClaudeBasePath(workdir) + const cacheData = await this.readCacheFile(claudePath) + + if (cacheData) { + logger.debug(`Loaded ${cacheData.plugins.length} plugins from cache`, { workdir }) + return cacheData.plugins + } + + logger.info('Cache read failed, rebuilding from filesystem', { workdir }) + return await this.rebuild(workdir) + } + + async upsert(workdir: string, plugin: InstalledPlugin): Promise { + const claudePath = this.deps.getClaudeBasePath(workdir) + let cacheData = await this.readCacheFile(claudePath) + let plugins = cacheData?.plugins + + if (!plugins) { + plugins = await this.rebuild(workdir) + cacheData = { + version: 1, + lastUpdated: Date.now(), + plugins + } + } + + const updatedPlugin: InstalledPlugin = { + ...plugin, + metadata: { + ...plugin.metadata, + installedAt: plugin.metadata.installedAt ?? Date.now() + } + } + + const index = plugins.findIndex((p) => p.filename === updatedPlugin.filename && p.type === updatedPlugin.type) + if (index >= 0) { + plugins[index] = updatedPlugin + } else { + plugins.push(updatedPlugin) + } + + const data: CachedPluginsData = { + version: cacheData?.version ?? 1, + lastUpdated: Date.now(), + plugins + } + + await fs.promises.mkdir(claudePath, { recursive: true }) + await this.writeCacheFile(claudePath, data) + } + + async remove(workdir: string, filename: string, type: PluginType): Promise { + const claudePath = this.deps.getClaudeBasePath(workdir) + let cacheData = await this.readCacheFile(claudePath) + let plugins = cacheData?.plugins + + if (!plugins) { + plugins = await this.rebuild(workdir) + cacheData = { + version: 1, + lastUpdated: Date.now(), + plugins + } + } + + const filtered = plugins.filter((p) => !(p.filename === filename && p.type === type)) + + const data: CachedPluginsData = { + version: cacheData?.version ?? 1, + lastUpdated: Date.now(), + plugins: filtered + } + + await fs.promises.mkdir(claudePath, { recursive: true }) + await this.writeCacheFile(claudePath, data) + } + + async rebuild(workdir: string): Promise { + logger.info('Rebuilding plugin cache from filesystem', { workdir }) + + const claudePath = this.deps.getClaudeBasePath(workdir) + + try { + await fs.promises.access(claudePath, fs.constants.R_OK) + } catch { + logger.warn('.claude directory not found, returning empty plugin list', { claudePath }) + return [] + } + + const plugins: InstalledPlugin[] = [] + + await Promise.all([ + this.collectFilePlugins(workdir, 'agent', plugins), + this.collectFilePlugins(workdir, 'command', plugins), + this.collectSkillPlugins(workdir, plugins) + ]) + + try { + const cacheData: CachedPluginsData = { + version: 1, + lastUpdated: Date.now(), + plugins + } + await this.writeCacheFile(claudePath, cacheData) + logger.info(`Rebuilt cache with ${plugins.length} plugins`, { workdir }) + } catch (error) { + logger.error('Failed to write cache file after rebuild', { + error: error instanceof Error ? error.message : String(error) + }) + } + + return plugins + } + + private async collectFilePlugins( + workdir: string, + type: Exclude, + plugins: InstalledPlugin[] + ): Promise { + const directory = this.deps.getClaudePluginDirectory(workdir, type) + + try { + await fs.promises.access(directory, fs.constants.R_OK) + } catch { + logger.debug(`${type} directory not found or not accessible`, { directory }) + return + } + + const files = await fs.promises.readdir(directory, { withFileTypes: true }) + + for (const file of files) { + if (!file.isFile()) { + continue + } + + const ext = path.extname(file.name).toLowerCase() + if (!this.deps.allowedExtensions.includes(ext)) { + continue + } + + try { + const filePath = path.join(directory, file.name) + const sourcePath = path.join(this.deps.getPluginDirectoryName(type), file.name) + const metadata = await parsePluginMetadata(filePath, sourcePath, this.deps.getPluginDirectoryName(type), type) + plugins.push({ filename: file.name, type, metadata }) + } catch (error) { + logger.warn(`Failed to parse ${type} plugin: ${file.name}`, { + error: error instanceof Error ? error.message : String(error) + }) + } + } + } + + private async collectSkillPlugins(workdir: string, plugins: InstalledPlugin[]): Promise { + const skillsPath = this.deps.getClaudePluginDirectory(workdir, 'skill') + const claudePath = this.deps.getClaudeBasePath(workdir) + + try { + await fs.promises.access(skillsPath, fs.constants.R_OK) + } catch { + logger.debug('Skills directory not found or not accessible', { skillsPath }) + return + } + + const skillDirectories = await findAllSkillDirectories(skillsPath, claudePath) + + for (const { folderPath, sourcePath } of skillDirectories) { + try { + const metadata = await parseSkillMetadata(folderPath, sourcePath, 'skills') + plugins.push({ filename: metadata.filename, type: 'skill', metadata }) + } catch (error) { + logger.warn(`Failed to parse skill plugin: ${sourcePath}`, { + error: error instanceof Error ? error.message : String(error) + }) + } + } + } + + private async readCacheFile(claudePath: string): Promise { + const cachePath = path.join(claudePath, 'plugins.json') + try { + const content = await fs.promises.readFile(cachePath, 'utf-8') + const data = JSON.parse(content) + return CachedPluginsDataSchema.parse(data) + } catch (err) { + logger.warn(`Failed to read cache file at ${cachePath}`, { + error: err instanceof Error ? err.message : String(err) + }) + return null + } + } + + private async writeCacheFile(claudePath: string, data: CachedPluginsData): Promise { + const cachePath = path.join(claudePath, 'plugins.json') + const tempPath = `${cachePath}.tmp` + + const content = JSON.stringify(data, null, 2) + await fs.promises.writeFile(tempPath, content, 'utf-8') + await fs.promises.rename(tempPath, cachePath) + } +} diff --git a/src/main/services/agents/plugins/PluginInstaller.ts b/src/main/services/agents/plugins/PluginInstaller.ts new file mode 100644 index 000000000..75acfc211 --- /dev/null +++ b/src/main/services/agents/plugins/PluginInstaller.ts @@ -0,0 +1,149 @@ +import { loggerService } from '@logger' +import { copyDirectoryRecursive, deleteDirectoryRecursive } from '@main/utils/fileOperations' +import type { PluginError } from '@types' +import * as crypto from 'crypto' +import * as fs from 'fs' + +const logger = loggerService.withContext('PluginInstaller') + +export class PluginInstaller { + async installFilePlugin(agentId: string, sourceAbsolutePath: string, destPath: string): Promise { + const tempPath = `${destPath}.tmp` + let fileCopied = false + + try { + await fs.promises.copyFile(sourceAbsolutePath, tempPath) + fileCopied = true + logger.debug('File copied to temp location', { agentId, tempPath }) + + await fs.promises.rename(tempPath, destPath) + logger.debug('File moved to final location', { agentId, destPath }) + } catch (error) { + if (fileCopied) { + await this.safeUnlink(tempPath, 'temp file') + } + throw this.toPluginError('install', error) + } + } + + async uninstallFilePlugin( + agentId: string, + filename: string, + type: 'agent' | 'command', + filePath: string + ): Promise { + try { + await fs.promises.unlink(filePath) + logger.debug('Plugin file deleted', { agentId, filename, type, filePath }) + } catch (error) { + const nodeError = error as NodeJS.ErrnoException + if (nodeError.code !== 'ENOENT') { + throw this.toPluginError('uninstall', error) + } + logger.warn('Plugin file already deleted', { agentId, filename, type, filePath }) + } + } + + async updateFilePluginContent(agentId: string, filePath: string, content: string): Promise { + try { + await fs.promises.access(filePath, fs.constants.W_OK) + } catch { + throw { + type: 'FILE_NOT_FOUND', + path: filePath + } as PluginError + } + + try { + await fs.promises.writeFile(filePath, content, 'utf8') + logger.debug('Plugin content written successfully', { + agentId, + filePath, + size: Buffer.byteLength(content, 'utf8') + }) + } catch (error) { + throw { + type: 'WRITE_FAILED', + path: filePath, + reason: error instanceof Error ? error.message : String(error) + } as PluginError + } + + return crypto.createHash('sha256').update(content).digest('hex') + } + + async installSkill(agentId: string, sourceAbsolutePath: string, destPath: string): Promise { + const logContext = logger.withContext('installSkill') + let folderCopied = false + const tempPath = `${destPath}.tmp` + + try { + try { + await fs.promises.access(destPath) + await deleteDirectoryRecursive(destPath) + logContext.info('Removed existing skill folder', { agentId, destPath }) + } catch { + // No existing folder + } + + await copyDirectoryRecursive(sourceAbsolutePath, tempPath) + folderCopied = true + logContext.info('Skill folder copied to temp location', { agentId, tempPath }) + + await fs.promises.rename(tempPath, destPath) + logContext.info('Skill folder moved to final location', { agentId, destPath }) + } catch (error) { + if (folderCopied) { + await this.safeRemoveDirectory(tempPath, 'temp folder') + } + throw this.toPluginError('install-skill', error) + } + } + + async uninstallSkill(agentId: string, folderName: string, skillPath: string): Promise { + const logContext = logger.withContext('uninstallSkill') + + try { + await deleteDirectoryRecursive(skillPath) + logContext.info('Skill folder deleted', { agentId, folderName, skillPath }) + } catch (error) { + const nodeError = error as NodeJS.ErrnoException + if (nodeError.code !== 'ENOENT') { + throw this.toPluginError('uninstall-skill', error) + } + logContext.warn('Skill folder already deleted', { agentId, folderName, skillPath }) + } + } + + private toPluginError(operation: string, error: unknown): PluginError { + return { + type: 'TRANSACTION_FAILED', + operation, + reason: error instanceof Error ? error.message : String(error) + } + } + + private async safeUnlink(targetPath: string, label: string): Promise { + try { + await fs.promises.unlink(targetPath) + logger.debug(`Rolled back ${label}`, { targetPath }) + } catch (unlinkError) { + logger.error(`Failed to rollback ${label}`, { + targetPath, + error: unlinkError instanceof Error ? unlinkError.message : String(unlinkError) + }) + } + } + + private async safeRemoveDirectory(targetPath: string, label: string): Promise { + try { + await deleteDirectoryRecursive(targetPath) + logger.info(`Rolled back ${label}`, { targetPath }) + } catch (unlinkError) { + logger.error(`Failed to rollback ${label}`, { + targetPath, + error: unlinkError instanceof Error ? unlinkError.message : String(unlinkError) + }) + } + } +} diff --git a/src/main/services/agents/plugins/PluginService.ts b/src/main/services/agents/plugins/PluginService.ts new file mode 100644 index 000000000..3076522a2 --- /dev/null +++ b/src/main/services/agents/plugins/PluginService.ts @@ -0,0 +1,614 @@ +import { loggerService } from '@logger' +import { parsePluginMetadata, parseSkillMetadata } from '@main/utils/markdownParser' +import type { + GetAgentResponse, + InstalledPlugin, + InstallPluginOptions, + ListAvailablePluginsResult, + PluginError, + PluginMetadata, + PluginType, + UninstallPluginOptions +} from '@types' +import { app } from 'electron' +import * as fs from 'fs' +import * as path from 'path' + +import { AgentService } from '../services/AgentService' +import { PluginCacheStore } from './PluginCacheStore' +import { PluginInstaller } from './PluginInstaller' + +const logger = loggerService.withContext('PluginService') + +interface PluginServiceConfig { + maxFileSize: number // bytes + cacheTimeout: number // milliseconds +} + +/** + * PluginService manages agent and command plugins from resources directory. + * + * Features: + * - Singleton pattern for consistent state management + * - Caching of available plugins for performance + * - Security validation (path traversal, file size, extensions) + * - Transactional install/uninstall operations + * - Integration with AgentService for metadata persistence + */ +export class PluginService { + private static instance: PluginService | null = null + + private availablePluginsCache: ListAvailablePluginsResult | null = null + private cacheTimestamp = 0 + private config: PluginServiceConfig + private readonly cacheStore: PluginCacheStore + private readonly installer: PluginInstaller + private readonly agentService: AgentService + + private readonly ALLOWED_EXTENSIONS = ['.md', '.markdown'] + + private constructor(config?: Partial) { + this.config = { + maxFileSize: config?.maxFileSize ?? 1024 * 1024, // 1MB default + cacheTimeout: config?.cacheTimeout ?? 5 * 60 * 1000 // 5 minutes default + } + this.agentService = AgentService.getInstance() + this.cacheStore = new PluginCacheStore({ + allowedExtensions: this.ALLOWED_EXTENSIONS, + getPluginDirectoryName: this.getPluginDirectoryName.bind(this), + getClaudeBasePath: this.getClaudeBasePath.bind(this), + getClaudePluginDirectory: this.getClaudePluginDirectory.bind(this), + getPluginsBasePath: this.getPluginsBasePath.bind(this) + }) + this.installer = new PluginInstaller() + + logger.info('PluginService initialized', { + maxFileSize: this.config.maxFileSize, + cacheTimeout: this.config.cacheTimeout + }) + } + + /** + * Get singleton instance + */ + static getInstance(config?: Partial): 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.cacheStore.listAvailableFilePlugins('agent'), + this.cacheStore.listAvailableFilePlugins('command'), + this.cacheStore.listAvailableSkills() + ]) + + const result: ListAvailablePluginsResult = { + agents, + commands, + skills, // NEW: include skills + total: agents.length + commands.length + skills.length + } + + // Update cache + this.availablePluginsCache = result + this.cacheTimestamp = now + + logger.info('Available plugins scanned', { + agentsCount: agents.length, + commandsCount: commands.length, + skillsCount: skills.length, + total: result.total + }) + + return result + } + + /** + * Install plugin with validation and transactional safety + */ + async install(options: InstallPluginOptions): Promise { + logger.info('Installing plugin', options) + + const context = await this.prepareInstallContext(options) + + if (options.type === 'skill') { + return await this.installSkillPlugin(options, context) + } + + return await this.installFilePlugin(options, context) + } + + private async prepareInstallContext(options: InstallPluginOptions): Promise<{ + agent: GetAgentResponse + workdir: string + sourceAbsolutePath: string + }> { + const agent = await this.getAgentOrThrow(options.agentId) + const workdir = this.getWorkdirOrThrow(agent, options.agentId) + + await this.validateWorkdir(agent, workdir) + + const sourceAbsolutePath = this.cacheStore.resolveSourcePath(options.sourcePath) + + return { agent, workdir, sourceAbsolutePath } + } + + private async installSkillPlugin( + options: InstallPluginOptions, + context: { + agent: GetAgentResponse + workdir: string + sourceAbsolutePath: string + } + ): Promise { + const { agent, workdir, sourceAbsolutePath } = context + + await this.cacheStore.ensureSkillSourceDirectory(sourceAbsolutePath, options.sourcePath) + + const metadata = await parseSkillMetadata(sourceAbsolutePath, options.sourcePath, 'skills') + const sanitizedFolderName = this.sanitizeFolderName(metadata.filename) + + await this.ensureClaudeDirectory(workdir, 'skill') + const destPath = this.getClaudePluginPath(workdir, 'skill', sanitizedFolderName) + + metadata.filename = sanitizedFolderName + + await this.installer.installSkill(agent.id, sourceAbsolutePath, destPath) + + const installedAt = Date.now() + const metadataWithInstall: PluginMetadata = { + ...metadata, + filename: sanitizedFolderName, + installedAt, + updatedAt: metadata.updatedAt ?? installedAt, + type: 'skill' + } + const installedPlugin: InstalledPlugin = { + filename: sanitizedFolderName, + type: 'skill', + metadata: metadataWithInstall + } + + await this.cacheStore.upsert(workdir, installedPlugin) + this.upsertAgentPlugin(agent, installedPlugin) + + logger.info('Skill installed successfully', { + agentId: options.agentId, + sourcePath: options.sourcePath, + folderName: sanitizedFolderName + }) + + return metadataWithInstall + } + + private async installFilePlugin( + options: InstallPluginOptions, + context: { + agent: GetAgentResponse + workdir: string + sourceAbsolutePath: string + } + ): Promise { + const { agent, workdir, sourceAbsolutePath } = context + + if (options.type === 'skill') { + throw { + type: 'INVALID_FILE_TYPE', + extension: options.type + } as PluginError + } + + const filePluginType: 'agent' | 'command' = options.type + + await this.cacheStore.validatePluginFile(sourceAbsolutePath, this.config.maxFileSize) + + const category = path.basename(path.dirname(options.sourcePath)) + const metadata = await parsePluginMetadata(sourceAbsolutePath, options.sourcePath, category, filePluginType) + + const sanitizedFilename = this.sanitizeFilename(metadata.filename) + metadata.filename = sanitizedFilename + + await this.ensureClaudeDirectory(workdir, filePluginType) + const destPath = this.getClaudePluginPath(workdir, filePluginType, sanitizedFilename) + + await this.installer.installFilePlugin(agent.id, sourceAbsolutePath, destPath) + + const installedAt = Date.now() + const metadataWithInstall: PluginMetadata = { + ...metadata, + filename: sanitizedFilename, + installedAt, + updatedAt: metadata.updatedAt ?? installedAt, + type: filePluginType + } + const installedPlugin: InstalledPlugin = { + filename: sanitizedFilename, + type: filePluginType, + metadata: metadataWithInstall + } + + await this.cacheStore.upsert(workdir, installedPlugin) + this.upsertAgentPlugin(agent, installedPlugin) + + logger.info('Plugin installed successfully', { + agentId: options.agentId, + filename: sanitizedFilename, + type: filePluginType + }) + + return metadataWithInstall + } + + /** + * Uninstall plugin with cleanup + */ + async uninstall(options: UninstallPluginOptions): Promise { + logger.info('Uninstalling plugin', options) + + const agent = await this.getAgentOrThrow(options.agentId) + const workdir = this.getWorkdirOrThrow(agent, options.agentId) + + await this.validateWorkdir(agent, workdir) + + if (options.type === 'skill') { + const sanitizedFolderName = this.sanitizeFolderName(options.filename) + const skillPath = this.getClaudePluginPath(workdir, 'skill', sanitizedFolderName) + + await this.installer.uninstallSkill(agent.id, sanitizedFolderName, skillPath) + await this.cacheStore.remove(workdir, sanitizedFolderName, 'skill') + this.removeAgentPlugin(agent, sanitizedFolderName, 'skill') + + logger.info('Skill uninstalled successfully', { + agentId: options.agentId, + folderName: sanitizedFolderName + }) + + return + } + + const sanitizedFilename = this.sanitizeFilename(options.filename) + const filePath = this.getClaudePluginPath(workdir, options.type, sanitizedFilename) + + await this.installer.uninstallFilePlugin(agent.id, sanitizedFilename, options.type, filePath) + await this.cacheStore.remove(workdir, sanitizedFilename, options.type) + this.removeAgentPlugin(agent, sanitizedFilename, options.type) + + logger.info('Plugin uninstalled successfully', { + agentId: options.agentId, + filename: sanitizedFilename, + type: options.type + }) + } + + /** + * List installed plugins for an agent (from database + filesystem validation) + */ + async listInstalled(agentId: string): Promise { + logger.debug('Listing installed plugins', { agentId }) + + const agent = await this.getAgentOrThrow(agentId) + + const workdir = agent.accessible_paths?.[0] + + if (!workdir) { + logger.warn('Agent has no accessible paths', { agentId }) + return [] + } + + const plugins = await this.listInstalledFromCache(workdir) + + logger.debug('Listed installed plugins from cache', { + agentId, + count: plugins.length + }) + + return plugins + } + + /** + * Invalidate plugin cache (for development/testing) + */ + invalidateCache(): void { + this.availablePluginsCache = null + this.cacheTimestamp = 0 + logger.info('Plugin cache invalidated') + } + + // ============================================================================ + // Cache File Management (for installed plugins) + // ============================================================================ + + /** + * Read cache file from .claude/plugins.json + * Returns null if cache doesn't exist or is invalid + */ + + /** + * List installed plugins from cache file + * Falls back to filesystem scan if cache is missing or corrupt + */ + async listInstalledFromCache(workdir: string): Promise { + logger.debug('Listing installed plugins from cache', { workdir }) + return await this.cacheStore.listInstalled(workdir) + } + + /** + * Read plugin content from source (resources directory) + */ + async readContent(sourcePath: string): Promise { + logger.info('Reading plugin content', { sourcePath }) + const content = await this.cacheStore.readSourceContent(sourcePath) + logger.debug('Plugin content read successfully', { + sourcePath, + size: content.length + }) + return content + } + + /** + * Write plugin content to installed plugin (in agent's .claude directory) + * Note: Only works for file-based plugins (agents/commands), not skills + */ + async writeContent(agentId: string, filename: string, type: PluginType, content: string): Promise { + logger.info('Writing plugin content', { agentId, filename, type }) + + const agent = await this.getAgentOrThrow(agentId) + const workdir = this.getWorkdirOrThrow(agent, agentId) + + await this.validateWorkdir(agent, workdir) + + // Check if plugin is installed + let installedPlugins = agent.installed_plugins ?? [] + if (installedPlugins.length === 0) { + installedPlugins = await this.cacheStore.listInstalled(workdir) + agent.installed_plugins = installedPlugins + } + const installedPlugin = installedPlugins.find((p) => p.filename === filename && p.type === type) + + if (!installedPlugin) { + throw { + type: 'PLUGIN_NOT_INSTALLED', + filename, + agentId + } as PluginError + } + + if (type === 'skill') { + throw { + type: 'INVALID_FILE_TYPE', + extension: type + } as PluginError + } + + const filePluginType = type as 'agent' | 'command' + const filePath = this.getClaudePluginPath(workdir, filePluginType, filename) + const newContentHash = await this.installer.updateFilePluginContent(agent.id, filePath, content) + + const updatedMetadata: PluginMetadata = { + ...installedPlugin.metadata, + contentHash: newContentHash, + size: Buffer.byteLength(content, 'utf8'), + updatedAt: Date.now(), + filename, + type: filePluginType + } + const updatedPlugin: InstalledPlugin = { + filename, + type: filePluginType, + metadata: updatedMetadata + } + + await this.cacheStore.upsert(workdir, updatedPlugin) + this.upsertAgentPlugin(agent, updatedPlugin) + + logger.info('Plugin content updated successfully', { + agentId, + filename, + type: filePluginType, + newContentHash + }) + } + + // ============================================================================ + // Private Helper Methods + // ============================================================================ + + /** + * Resolve plugin type to directory name under .claude + */ + private getPluginDirectoryName(type: PluginType): 'agents' | 'commands' | 'skills' { + if (type === 'agent') { + return 'agents' + } + if (type === 'command') { + return 'commands' + } + return 'skills' + } + + /** + * Get the base .claude directory for a workdir + */ + private getClaudeBasePath(workdir: string): string { + return path.join(workdir, '.claude') + } + + /** + * Get the directory for a specific plugin type inside .claude + */ + private getClaudePluginDirectory(workdir: string, type: PluginType): string { + return path.join(this.getClaudeBasePath(workdir), this.getPluginDirectoryName(type)) + } + + /** + * Get the absolute path for a plugin file/folder inside .claude + */ + private getClaudePluginPath(workdir: string, type: PluginType, filename: string): string { + return path.join(this.getClaudePluginDirectory(workdir, type), filename) + } + + /** + * Get absolute path to plugins directory (handles packaged vs dev) + */ + private getPluginsBasePath(): string { + // Use the utility function which handles both dev and production correctly + if (app.isPackaged) { + return path.join(process.resourcesPath, 'claude-code-plugins') + } + return path.join(__dirname, '../../node_modules/claude-code-plugins/plugins') + } + + /** + * Validate source path to prevent path traversal attacks + */ + private async getAgentOrThrow(agentId: string): Promise { + const agent = await this.agentService.getAgent(agentId) + if (!agent) { + throw { + type: 'INVALID_WORKDIR', + agentId, + workdir: '', + message: 'Agent not found' + } as PluginError + } + return agent + } + + private getWorkdirOrThrow(agent: GetAgentResponse, agentId: string): string { + const workdir = agent.accessible_paths?.[0] + if (!workdir) { + throw { + type: 'INVALID_WORKDIR', + agentId, + workdir: '', + message: 'Agent has no accessible paths' + } as PluginError + } + return workdir + } + + /** + * Validate workdir against agent's accessible paths + */ + private async validateWorkdir(agent: GetAgentResponse, workdir: string): Promise { + // Verify workdir is in agent's accessible_paths + if (!agent.accessible_paths?.includes(workdir)) { + throw { + type: 'INVALID_WORKDIR', + workdir, + agentId: agent.id, + message: 'Workdir not in agent accessible paths' + } as PluginError + } + + // Verify workdir exists and is accessible + try { + await fs.promises.access(workdir, fs.constants.R_OK | fs.constants.W_OK) + } catch (error) { + throw { + type: 'WORKDIR_NOT_FOUND', + workdir, + message: 'Workdir does not exist or is not accessible' + } as PluginError + } + } + + private upsertAgentPlugin(agent: GetAgentResponse, plugin: InstalledPlugin): void { + const existing = agent.installed_plugins ?? [] + const filtered = existing.filter((p) => !(p.filename === plugin.filename && p.type === plugin.type)) + agent.installed_plugins = [...filtered, plugin] + } + + private removeAgentPlugin(agent: GetAgentResponse, filename: string, type: PluginType): void { + if (!agent.installed_plugins) { + agent.installed_plugins = [] + return + } + agent.installed_plugins = agent.installed_plugins.filter((p) => !(p.filename === filename && p.type === type)) + } + + /** + * Sanitize filename to remove unsafe characters (for agents/commands) + */ + private sanitizeFilename(filename: string): string { + // Remove path separators + let sanitized = filename.replace(/[/\\]/g, '_') + // Remove null bytes using String method to avoid control-regex lint error + sanitized = sanitized.replace(new RegExp(String.fromCharCode(0), 'g'), '') + // Limit to safe characters (alphanumeric, dash, underscore, dot) + sanitized = sanitized.replace(/[^a-zA-Z0-9._-]/g, '_') + + // Ensure .md extension + if (!sanitized.endsWith('.md') && !sanitized.endsWith('.markdown')) { + sanitized += '.md' + } + + return sanitized + } + + /** + * Sanitize folder name for skills (different rules than file names) + * NO dots allowed to avoid confusion with file extensions + */ + private sanitizeFolderName(folderName: string): string { + // Remove path separators + let sanitized = folderName.replace(/[/\\]/g, '_') + // Remove null bytes using String method to avoid control-regex lint error + sanitized = sanitized.replace(new RegExp(String.fromCharCode(0), 'g'), '') + // Limit to safe characters (alphanumeric, dash, underscore) + // NOTE: No dots allowed to avoid confusion with file extensions + sanitized = sanitized.replace(/[^a-zA-Z0-9_-]/g, '_') + + // Validate no extension was provided + if (folderName.includes('.')) { + logger.warn('Skill folder name contained dots, sanitized', { + original: folderName, + sanitized + }) + } + + return sanitized + } + + /** + * Ensure .claude subdirectory exists for the given plugin type + */ + private async ensureClaudeDirectory(workdir: string, type: PluginType): Promise { + const typeDir = this.getClaudePluginDirectory(workdir, type) + + try { + await fs.promises.mkdir(typeDir, { recursive: true }) + logger.debug('Ensured directory exists', { typeDir }) + } catch (error) { + logger.error('Failed to create directory', { + typeDir, + error: error instanceof Error ? error.message : String(error) + }) + throw { + type: 'PERMISSION_DENIED', + path: typeDir + } as PluginError + } + } +} + +export const pluginService = PluginService.getInstance() diff --git a/src/main/services/agents/services/AgentService.ts b/src/main/services/agents/services/AgentService.ts index 53af37f67..c3ae2fb79 100644 --- a/src/main/services/agents/services/AgentService.ts +++ b/src/main/services/agents/services/AgentService.ts @@ -1,5 +1,7 @@ import path from 'node:path' +import { loggerService } from '@logger' +import { pluginService } from '@main/services/agents/plugins/PluginService' import { getDataPath } from '@main/utils' import { AgentBaseSchema, @@ -17,6 +19,8 @@ import { BaseService } from '../BaseService' import { type AgentRow, agentsTable, type InsertAgentRow } from '../database/schema' import { AgentModelField } from '../errors' +const logger = loggerService.withContext('AgentService') + export class AgentService extends BaseService { private static instance: AgentService | null = null private readonly modelFields: AgentModelField[] = ['model', 'plan_model', 'small_model'] @@ -92,6 +96,24 @@ export class AgentService extends BaseService { const agent = this.deserializeJsonFields(result[0]) as GetAgentResponse agent.tools = await this.listMcpTools(agent.type, agent.mcps) + + // Load installed_plugins from cache file instead of database + const workdir = agent.accessible_paths?.[0] + if (workdir) { + try { + agent.installed_plugins = await pluginService.listInstalledFromCache(workdir) + } catch (error) { + // Log error but don't fail the request + logger.warn(`Failed to load installed plugins for agent ${id}`, { + workdir, + error: error instanceof Error ? error.message : String(error) + }) + agent.installed_plugins = [] + } + } else { + agent.installed_plugins = [] + } + return agent } diff --git a/src/renderer/src/pages/home/Messages/Tools/ToolPermissionRequestCard.tsx b/src/renderer/src/pages/home/Messages/Tools/ToolPermissionRequestCard.tsx index 4d51ce06f..1ef93a534 100644 --- a/src/renderer/src/pages/home/Messages/Tools/ToolPermissionRequestCard.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/ToolPermissionRequestCard.tsx @@ -1,5 +1,5 @@ import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk' -import { Button, ButtonGroup, Chip, ScrollShadow } from '@heroui/react' +import { Button, Chip, ScrollShadow } from '@heroui/react' import { loggerService } from '@logger' import { useAppDispatch, useAppSelector } from '@renderer/store' import { selectPendingPermissionByToolName, toolPermissionsActions } from '@renderer/store/toolPermissions' @@ -54,7 +54,6 @@ export function ToolPermissionRequestCard({ toolResponse }: Props) { const isSubmittingAllow = request?.status === 'submitting-allow' const isSubmittingDeny = request?.status === 'submitting-deny' const isSubmitting = isSubmittingAllow || isSubmittingDeny - const hasSuggestions = (request?.suggestions?.length ?? 0) > 0 const handleDecision = useCallback( async ( @@ -147,37 +146,16 @@ export function ToolPermissionRequestCard({ toolResponse }: Props) { {t('agent.toolPermission.button.cancel')} - {hasSuggestions ? ( - - - - - ) : ( - - )} + + + + + + + {selectedFolderPath || t('settings.data.export_to_phone.lan.noZipSelected')} + + + + + + + + {showCloseConfirm && ( + +
+
+ ⚠️ + + {t('settings.data.export_to_phone.lan.confirm_close_title')} + +
+ + {t('settings.data.export_to_phone.lan.confirm_close_message')} + +
+ + +
+
+
+ )} + + )} + + + ) +} + +const TopViewKey = 'ExportToPhoneLanPopup' + +export default class ExportToPhoneLanPopup { + static topviewId = 0 + static hide() { + TopView.hide(TopViewKey) + } + static show() { + return new Promise((resolve) => { + TopView.show( + { + resolve(v) + TopView.hide(TopViewKey) + }} + />, + TopViewKey + ) + }) + } +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 98da54794..9eaab8407 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1047,6 +1047,7 @@ "clear": "Clear", "close": "Close", "collapse": "Collapse", + "completed": "Completed", "confirm": "Confirm", "copied": "Copied", "copy": "Copy", @@ -3038,6 +3039,46 @@ "title": "Export Menu Settings", "yuque": "Export to Yuque" }, + "export_to_phone": { + "confirm": { + "button": "Select backup file" + }, + "content": "Export some data, including chat logs and settings. Please note that the backup process may take some time. Thank you for your patience.", + "lan": { + "auto_close_tip": "Auto-closing in {{seconds}} seconds...", + "confirm_close_message": "File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?", + "confirm_close_title": "Confirm Close", + "connected": "Connected", + "connection_failed": "Connection failed", + "content": "Please ensure your computer and phone are on the same network for LAN transfer. Open the Cherry Studio App to scan this QR code.", + "error": { + "init_failed": "Initialization failed", + "no_file": "No file selected", + "no_ip": "Unable to get IP address", + "send_failed": "Failed to send file" + }, + "force_close": "Force Close", + "generating_qr": "Generating QR code...", + "noZipSelected": "No compressed file selected", + "scan_qr": "Please scan QR code with your phone", + "selectZip": "Select a compressed file", + "sendZip": "Begin data recovery", + "status": { + "completed": "Transfer completed", + "connected": "Connected", + "connecting": "Connecting...", + "disconnected": "Disconnected", + "error": "Connection error", + "initializing": "Initializing connection...", + "preparing": "Preparing transfer...", + "sending": "Transferring {{progress}}%", + "waiting_qr_scan": "Please scan QR code to connect" + }, + "title": "LAN transmission", + "transfer_progress": "Transfer progress" + }, + "title": "Export to phone" + }, "hour_interval_one": "{{count}} hour", "hour_interval_other": "{{count}} hours", "joplin": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index af6a3c147..c3f486c0b 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1047,6 +1047,7 @@ "clear": "清除", "close": "关闭", "collapse": "折叠", + "completed": "完成", "confirm": "确认", "copied": "已复制", "copy": "复制", @@ -3038,6 +3039,46 @@ "title": "导出菜单设置", "yuque": "导出到语雀" }, + "export_to_phone": { + "confirm": { + "button": "选择备份文件" + }, + "content": "导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。", + "lan": { + "auto_close_tip": "{{seconds}} 秒后自动关闭...", + "confirm_close_message": "文件正在传输中,关闭将中断传输。确定要强制关闭吗?", + "confirm_close_title": "确认关闭", + "connected": "连接成功", + "connection_failed": "连接失败", + "content": "请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。", + "error": { + "init_failed": "初始化失败", + "no_file": "未选择文件", + "no_ip": "无法获取 IP 地址", + "send_failed": "发送文件失败" + }, + "force_close": "强制关闭", + "generating_qr": "正在生成二维码...", + "noZipSelected": "未选择压缩文件", + "scan_qr": "请使用手机扫码连接", + "selectZip": "选择压缩文件", + "sendZip": "开始恢复数据", + "status": { + "completed": "传输完成", + "connected": "连接成功", + "connecting": "正在连接中...", + "disconnected": "连接已断开", + "error": "连接出错", + "initializing": "正在初始化连接...", + "preparing": "准备传输中...", + "sending": "传输中 {{progress}}%", + "waiting_qr_scan": "请扫描二维码连接" + }, + "title": "局域网传输", + "transfer_progress": "传输进度" + }, + "title": "导出至手机" + }, "hour_interval_one": "{{count}} 小时", "hour_interval_other": "{{count}} 小时", "joplin": { diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 8bd8ff17e..58174e694 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1047,6 +1047,7 @@ "clear": "清除", "close": "關閉", "collapse": "折疊", + "completed": "[to be translated]:Completed", "confirm": "確認", "copied": "已複製", "copy": "複製", @@ -3038,6 +3039,46 @@ "title": "匯出選單設定", "yuque": "匯出到語雀" }, + "export_to_phone": { + "confirm": { + "button": "選擇備份檔案" + }, + "content": "匯出部分數據,包括聊天記錄、設定。請注意,備份過程可能需要一些時間,感謝您的耐心等候。", + "lan": { + "auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...", + "confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?", + "confirm_close_title": "[to be translated]:Confirm Close", + "connected": "[to be translated]:Connected", + "connection_failed": "[to be translated]:Connection failed", + "content": "請確保電腦和手機處於同一網路以使用區域網路傳輸。請打開 Cherry Studio App 掃描此 QR 碼。", + "error": { + "init_failed": "[to be translated]:Initialization failed", + "no_file": "[to be translated]:No file selected", + "no_ip": "[to be translated]:Unable to get IP address", + "send_failed": "[to be translated]:Failed to send file" + }, + "force_close": "[to be translated]:Force Close", + "generating_qr": "[to be translated]:Generating QR code...", + "noZipSelected": "未選取壓縮檔案", + "scan_qr": "[to be translated]:Please scan QR code with your phone", + "selectZip": "選擇壓縮檔案", + "sendZip": "開始恢復資料", + "status": { + "completed": "[to be translated]:Transfer completed", + "connected": "[to be translated]:Connected", + "connecting": "[to be translated]:Connecting...", + "disconnected": "[to be translated]:Disconnected", + "error": "[to be translated]:Connection error", + "initializing": "[to be translated]:Initializing connection...", + "preparing": "[to be translated]:Preparing transfer...", + "sending": "[to be translated]:Transferring {{progress}}%", + "waiting_qr_scan": "[to be translated]:Please scan QR code to connect" + }, + "title": "區域網路傳輸", + "transfer_progress": "[to be translated]:Transfer progress" + }, + "title": "匯出手機" + }, "hour_interval_one": "{{count}} 小時", "hour_interval_other": "{{count}} 小時", "joplin": { diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index 28d03c064..49a68809e 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -1047,6 +1047,7 @@ "clear": "Löschen", "close": "Schließen", "collapse": "Einklappen", + "completed": "[to be translated]:Completed", "confirm": "Bestätigen", "copied": "Kopiert", "copy": "Kopieren", @@ -3038,6 +3039,46 @@ "title": "Export-Menü-Einstellungen", "yuque": "Nach Yuque exportieren" }, + "export_to_phone": { + "confirm": { + "button": "[to be translated]:Select backup file" + }, + "content": "[to be translated]:Export some data, including chat logs and settings. Please note that the backup process may take some time. Thank you for your patience.", + "lan": { + "auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...", + "confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?", + "confirm_close_title": "[to be translated]:Confirm Close", + "connected": "[to be translated]:Connected", + "connection_failed": "[to be translated]:Connection failed", + "content": "[to be translated]:Please ensure your computer and phone are on the same network for LAN transfer. Open the Cherry Studio App to scan this QR code.", + "error": { + "init_failed": "[to be translated]:Initialization failed", + "no_file": "[to be translated]:No file selected", + "no_ip": "[to be translated]:Unable to get IP address", + "send_failed": "[to be translated]:Failed to send file" + }, + "force_close": "[to be translated]:Force Close", + "generating_qr": "[to be translated]:Generating QR code...", + "noZipSelected": "[to be translated]:No compressed file selected", + "scan_qr": "[to be translated]:Please scan QR code with your phone", + "selectZip": "[to be translated]:Select a compressed file", + "sendZip": "[to be translated]:Begin data recovery", + "status": { + "completed": "[to be translated]:Transfer completed", + "connected": "[to be translated]:Connected", + "connecting": "[to be translated]:Connecting...", + "disconnected": "[to be translated]:Disconnected", + "error": "[to be translated]:Connection error", + "initializing": "[to be translated]:Initializing connection...", + "preparing": "[to be translated]:Preparing transfer...", + "sending": "[to be translated]:Transferring {{progress}}%", + "waiting_qr_scan": "[to be translated]:Please scan QR code to connect" + }, + "title": "[to be translated]:LAN transmission", + "transfer_progress": "[to be translated]:Transfer progress" + }, + "title": "[to be translated]:Export to phone" + }, "hour_interval_one": "{{count}} Stunde", "hour_interval_other": "{{count}} Stunden", "joplin": { diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index ddea9edff..5a38b8051 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -1047,6 +1047,7 @@ "clear": "Καθαρισμός", "close": "Κλείσιμο", "collapse": "Σύμπτυξη", + "completed": "[to be translated]:Completed", "confirm": "Επιβεβαίωση", "copied": "Αντιγράφηκε", "copy": "Αντιγραφή", @@ -3038,6 +3039,46 @@ "title": "Εξαγωγή ρυθμίσεων μενού", "yuque": "Εξαγωγή στο Yuque" }, + "export_to_phone": { + "confirm": { + "button": "[to be translated]:选择备份文件" + }, + "content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。", + "lan": { + "auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...", + "confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?", + "confirm_close_title": "[to be translated]:Confirm Close", + "connected": "[to be translated]:Connected", + "connection_failed": "[to be translated]:Connection failed", + "content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。", + "error": { + "init_failed": "[to be translated]:Initialization failed", + "no_file": "[to be translated]:No file selected", + "no_ip": "[to be translated]:Unable to get IP address", + "send_failed": "[to be translated]:Failed to send file" + }, + "force_close": "[to be translated]:Force Close", + "generating_qr": "[to be translated]:Generating QR code...", + "noZipSelected": "[to be translated]:未选择压缩文件", + "scan_qr": "[to be translated]:Please scan QR code with your phone", + "selectZip": "[to be translated]:选择压缩文件", + "sendZip": "[to be translated]:开始恢复数据", + "status": { + "completed": "[to be translated]:Transfer completed", + "connected": "[to be translated]:Connected", + "connecting": "[to be translated]:Connecting...", + "disconnected": "[to be translated]:Disconnected", + "error": "[to be translated]:Connection error", + "initializing": "[to be translated]:Initializing connection...", + "preparing": "[to be translated]:Preparing transfer...", + "sending": "[to be translated]:Transferring {{progress}}%", + "waiting_qr_scan": "[to be translated]:Please scan QR code to connect" + }, + "title": "[to be translated]:局域网传输", + "transfer_progress": "[to be translated]:Transfer progress" + }, + "title": "[to be translated]:导出至手机" + }, "hour_interval_one": "{{count}} ώρα", "hour_interval_other": "{{count}} ώρες", "joplin": { diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 99e18afc3..b04258151 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -1047,6 +1047,7 @@ "clear": "Limpiar", "close": "Cerrar", "collapse": "Colapsar", + "completed": "[to be translated]:Completed", "confirm": "Confirmar", "copied": "Copiado", "copy": "Copiar", @@ -3038,6 +3039,46 @@ "title": "Exportar configuración del menú", "yuque": "Exportar a Yuque" }, + "export_to_phone": { + "confirm": { + "button": "[to be translated]:选择备份文件" + }, + "content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。", + "lan": { + "auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...", + "confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?", + "confirm_close_title": "[to be translated]:Confirm Close", + "connected": "[to be translated]:Connected", + "connection_failed": "[to be translated]:Connection failed", + "content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。", + "error": { + "init_failed": "[to be translated]:Initialization failed", + "no_file": "[to be translated]:No file selected", + "no_ip": "[to be translated]:Unable to get IP address", + "send_failed": "[to be translated]:Failed to send file" + }, + "force_close": "[to be translated]:Force Close", + "generating_qr": "[to be translated]:Generating QR code...", + "noZipSelected": "[to be translated]:未选择压缩文件", + "scan_qr": "[to be translated]:Please scan QR code with your phone", + "selectZip": "[to be translated]:选择压缩文件", + "sendZip": "[to be translated]:开始恢复数据", + "status": { + "completed": "[to be translated]:Transfer completed", + "connected": "[to be translated]:Connected", + "connecting": "[to be translated]:Connecting...", + "disconnected": "[to be translated]:Disconnected", + "error": "[to be translated]:Connection error", + "initializing": "[to be translated]:Initializing connection...", + "preparing": "[to be translated]:Preparing transfer...", + "sending": "[to be translated]:Transferring {{progress}}%", + "waiting_qr_scan": "[to be translated]:Please scan QR code to connect" + }, + "title": "[to be translated]:局域网传输", + "transfer_progress": "[to be translated]:Transfer progress" + }, + "title": "[to be translated]:导出至手机" + }, "hour_interval_one": "{{count}} hora", "hour_interval_other": "{{count}} horas", "joplin": { diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 06c71c60a..b9c68d9a8 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -1047,6 +1047,7 @@ "clear": "Effacer", "close": "Fermer", "collapse": "Réduire", + "completed": "[to be translated]:Completed", "confirm": "Confirmer", "copied": "Copié", "copy": "Copier", @@ -3038,6 +3039,46 @@ "title": "Exporter les paramètres du menu", "yuque": "Exporter vers Yuque" }, + "export_to_phone": { + "confirm": { + "button": "[to be translated]:选择备份文件" + }, + "content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。", + "lan": { + "auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...", + "confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?", + "confirm_close_title": "[to be translated]:Confirm Close", + "connected": "[to be translated]:Connected", + "connection_failed": "[to be translated]:Connection failed", + "content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。", + "error": { + "init_failed": "[to be translated]:Initialization failed", + "no_file": "[to be translated]:No file selected", + "no_ip": "[to be translated]:Unable to get IP address", + "send_failed": "[to be translated]:Failed to send file" + }, + "force_close": "[to be translated]:Force Close", + "generating_qr": "[to be translated]:Generating QR code...", + "noZipSelected": "[to be translated]:未选择压缩文件", + "scan_qr": "[to be translated]:Please scan QR code with your phone", + "selectZip": "[to be translated]:选择压缩文件", + "sendZip": "[to be translated]:开始恢复数据", + "status": { + "completed": "[to be translated]:Transfer completed", + "connected": "[to be translated]:Connected", + "connecting": "[to be translated]:Connecting...", + "disconnected": "[to be translated]:Disconnected", + "error": "[to be translated]:Connection error", + "initializing": "[to be translated]:Initializing connection...", + "preparing": "[to be translated]:Preparing transfer...", + "sending": "[to be translated]:Transferring {{progress}}%", + "waiting_qr_scan": "[to be translated]:Please scan QR code to connect" + }, + "title": "[to be translated]:局域网传输", + "transfer_progress": "[to be translated]:Transfer progress" + }, + "title": "[to be translated]:导出至手机" + }, "hour_interval_one": "{{count}} heure", "hour_interval_other": "{{count}} heures", "joplin": { diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 6bd095a2b..15b2fb111 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -1047,6 +1047,7 @@ "clear": "クリア", "close": "閉じる", "collapse": "折りたたむ", + "completed": "[to be translated]:Completed", "confirm": "確認", "copied": "コピーされました", "copy": "コピー", @@ -3038,6 +3039,46 @@ "title": "エクスポートメニュー設定", "yuque": "語雀にエクスポート" }, + "export_to_phone": { + "confirm": { + "button": "[to be translated]:选择备份文件" + }, + "content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。", + "lan": { + "auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...", + "confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?", + "confirm_close_title": "[to be translated]:Confirm Close", + "connected": "[to be translated]:Connected", + "connection_failed": "[to be translated]:Connection failed", + "content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。", + "error": { + "init_failed": "[to be translated]:Initialization failed", + "no_file": "[to be translated]:No file selected", + "no_ip": "[to be translated]:Unable to get IP address", + "send_failed": "[to be translated]:Failed to send file" + }, + "force_close": "[to be translated]:Force Close", + "generating_qr": "[to be translated]:Generating QR code...", + "noZipSelected": "[to be translated]:未选择压缩文件", + "scan_qr": "[to be translated]:Please scan QR code with your phone", + "selectZip": "[to be translated]:选择压缩文件", + "sendZip": "[to be translated]:开始恢复数据", + "status": { + "completed": "[to be translated]:Transfer completed", + "connected": "[to be translated]:Connected", + "connecting": "[to be translated]:Connecting...", + "disconnected": "[to be translated]:Disconnected", + "error": "[to be translated]:Connection error", + "initializing": "[to be translated]:Initializing connection...", + "preparing": "[to be translated]:Preparing transfer...", + "sending": "[to be translated]:Transferring {{progress}}%", + "waiting_qr_scan": "[to be translated]:Please scan QR code to connect" + }, + "title": "[to be translated]:局域网传输", + "transfer_progress": "[to be translated]:Transfer progress" + }, + "title": "[to be translated]:导出至手机" + }, "hour_interval_one": "{{count}} 時間", "hour_interval_other": "{{count}} 時間", "joplin": { diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 3ff8c970b..0253dba3c 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -1047,6 +1047,7 @@ "clear": "Limpar", "close": "Fechar", "collapse": "Recolher", + "completed": "[to be translated]:Completed", "confirm": "Confirmar", "copied": "Copiado", "copy": "Copiar", @@ -3038,6 +3039,46 @@ "title": "Exportar Configurações do Menu", "yuque": "Exportar para Yuque" }, + "export_to_phone": { + "confirm": { + "button": "[to be translated]:选择备份文件" + }, + "content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。", + "lan": { + "auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...", + "confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?", + "confirm_close_title": "[to be translated]:Confirm Close", + "connected": "[to be translated]:Connected", + "connection_failed": "[to be translated]:Connection failed", + "content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。", + "error": { + "init_failed": "[to be translated]:Initialization failed", + "no_file": "[to be translated]:No file selected", + "no_ip": "[to be translated]:Unable to get IP address", + "send_failed": "[to be translated]:Failed to send file" + }, + "force_close": "[to be translated]:Force Close", + "generating_qr": "[to be translated]:Generating QR code...", + "noZipSelected": "[to be translated]:未选择压缩文件", + "scan_qr": "[to be translated]:Please scan QR code with your phone", + "selectZip": "[to be translated]:选择压缩文件", + "sendZip": "[to be translated]:开始恢复数据", + "status": { + "completed": "[to be translated]:Transfer completed", + "connected": "[to be translated]:Connected", + "connecting": "[to be translated]:Connecting...", + "disconnected": "[to be translated]:Disconnected", + "error": "[to be translated]:Connection error", + "initializing": "[to be translated]:Initializing connection...", + "preparing": "[to be translated]:Preparing transfer...", + "sending": "[to be translated]:Transferring {{progress}}%", + "waiting_qr_scan": "[to be translated]:Please scan QR code to connect" + }, + "title": "[to be translated]:局域网传输", + "transfer_progress": "[to be translated]:Transfer progress" + }, + "title": "[to be translated]:导出至手机" + }, "hour_interval_one": "{{count}} hora", "hour_interval_other": "{{count}} horas", "joplin": { diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index f65048d6b..7e31339a5 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -1047,6 +1047,7 @@ "clear": "Очистить", "close": "Закрыть", "collapse": "Свернуть", + "completed": "[to be translated]:Completed", "confirm": "Подтверждение", "copied": "Скопировано", "copy": "Копировать", @@ -3038,6 +3039,46 @@ "title": "Настройки меню экспорта", "yuque": "Экспорт в Yuque" }, + "export_to_phone": { + "confirm": { + "button": "[to be translated]:选择备份文件" + }, + "content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。", + "lan": { + "auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...", + "confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?", + "confirm_close_title": "[to be translated]:Confirm Close", + "connected": "[to be translated]:Connected", + "connection_failed": "[to be translated]:Connection failed", + "content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。", + "error": { + "init_failed": "[to be translated]:Initialization failed", + "no_file": "[to be translated]:No file selected", + "no_ip": "[to be translated]:Unable to get IP address", + "send_failed": "[to be translated]:Failed to send file" + }, + "force_close": "[to be translated]:Force Close", + "generating_qr": "[to be translated]:Generating QR code...", + "noZipSelected": "[to be translated]:未选择压缩文件", + "scan_qr": "[to be translated]:Please scan QR code with your phone", + "selectZip": "[to be translated]:选择压缩文件", + "sendZip": "[to be translated]:开始恢复数据", + "status": { + "completed": "[to be translated]:Transfer completed", + "connected": "[to be translated]:Connected", + "connecting": "[to be translated]:Connecting...", + "disconnected": "[to be translated]:Disconnected", + "error": "[to be translated]:Connection error", + "initializing": "[to be translated]:Initializing connection...", + "preparing": "[to be translated]:Preparing transfer...", + "sending": "[to be translated]:Transferring {{progress}}%", + "waiting_qr_scan": "[to be translated]:Please scan QR code to connect" + }, + "title": "[to be translated]:局域网传输", + "transfer_progress": "[to be translated]:Transfer progress" + }, + "title": "[to be translated]:导出至手机" + }, "hour_interval_one": "{{count}} час", "hour_interval_other": "{{count}} часов", "joplin": { diff --git a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx index 4b00993f6..aa6078b6b 100644 --- a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx @@ -3,13 +3,17 @@ import { CloudSyncOutlined, FileSearchOutlined, LoadingOutlined, + WifiOutlined, YuqueOutlined } from '@ant-design/icons' +import { Button } from '@heroui/button' +import { Switch } from '@heroui/switch' import DividerWithText from '@renderer/components/DividerWithText' import { NutstoreIcon } from '@renderer/components/Icons/NutstoreIcons' import { HStack } from '@renderer/components/Layout' import ListItem from '@renderer/components/ListItem' import BackupPopup from '@renderer/components/Popups/BackupPopup' +import ExportToPhoneLanPopup from '@renderer/components/Popups/ExportToPhoneLanPopup' import RestorePopup from '@renderer/components/Popups/RestorePopup' import { useTheme } from '@renderer/context/ThemeProvider' import { useKnowledgeFiles } from '@renderer/hooks/useKnowledgeFiles' @@ -20,7 +24,7 @@ import { setSkipBackupFile as _setSkipBackupFile } from '@renderer/store/setting import { AppInfo } from '@renderer/types' import { formatFileSize } from '@renderer/utils' import { occupiedDirs } from '@shared/config/constant' -import { Button, Progress, Switch, Typography } from 'antd' +import { Progress, Typography } from 'antd' import { FileText, FolderCog, FolderInput, FolderOpen, SaveIcon, Sparkle } from 'lucide-react' import { FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -290,12 +294,16 @@ const DataSettings: FC = () => {
{ + defaultSelected={shouldCopyData} + onValueChange={(checked) => { shouldCopyData = checked }} - style={{ marginRight: '8px' }} - /> + size="sm"> + + {t('settings.data.app_data.copy_data_option')} + + + {t('settings.data.app_data.copy_data_option')} @@ -605,10 +613,10 @@ const DataSettings: FC = () => { {t('settings.general.backup.title')} - - @@ -616,11 +624,24 @@ const DataSettings: FC = () => { {t('settings.data.backup.skip_file_data_title')} - + {t('settings.data.backup.skip_file_data_help')} + + + {t('settings.data.export_to_phone.title')} + + + + {t('settings.data.data.title')} @@ -635,7 +656,9 @@ const DataSettings: FC = () => { handleOpenPath(appInfo?.appDataPath)} style={{ flexShrink: 0 }} /> - + @@ -648,7 +671,7 @@ const DataSettings: FC = () => { handleOpenPath(appInfo?.logsPath)} style={{ flexShrink: 0 }} /> - @@ -658,7 +681,9 @@ const DataSettings: FC = () => { {t('settings.data.app_knowledge.label')} - + @@ -668,14 +693,16 @@ const DataSettings: FC = () => { {cacheSize && ({cacheSize}MB)} - + {t('settings.general.reset.title')} - diff --git a/yarn.lock b/yarn.lock index 06d44e14d..fba0420fd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11073,6 +11073,13 @@ __metadata: languageName: node linkType: hard +"@socket.io/component-emitter@npm:~3.1.0": + version: 3.1.2 + resolution: "@socket.io/component-emitter@npm:3.1.2" + checksum: 10c0/c4242bad66f67e6f7b712733d25b43cbb9e19a595c8701c3ad99cbeb5901555f78b095e24852f862fffb43e96f1d8552e62def885ca82ae1bb05da3668fd87d7 + languageName: node + linkType: hard + "@standard-schema/spec@npm:^1.0.0": version: 1.0.0 resolution: "@standard-schema/spec@npm:1.0.0" @@ -12125,7 +12132,7 @@ __metadata: languageName: node linkType: hard -"@types/cors@npm:^2.8.19": +"@types/cors@npm:^2.8.12, @types/cors@npm:^2.8.19": version: 2.8.19 resolution: "@types/cors@npm:2.8.19" dependencies: @@ -12673,6 +12680,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:>=10.0.0": + version: 24.3.1 + resolution: "@types/node@npm:24.3.1" + dependencies: + undici-types: "npm:~7.10.0" + checksum: 10c0/99b86fc32294fcd61136ca1f771026443a1e370e9f284f75e243b29299dd878e18c193deba1ce29a374932db4e30eb80826e1049b9aad02d36f5c30b94b6f928 + languageName: node + linkType: hard + "@types/node@npm:^18.11.18": version: 18.19.86 resolution: "@types/node@npm:18.19.86" @@ -14108,6 +14124,7 @@ __metadata: pdf-parse: "npm:^1.1.1" playwright: "npm:^1.55.1" proxy-agent: "npm:^6.5.0" + qrcode.react: "npm:^4.2.0" react: "npm:^19.2.0" react-dom: "npm:^19.2.0" react-error-boundary: "npm:^6.0.0" @@ -14139,6 +14156,7 @@ __metadata: selection-hook: "npm:^1.0.12" sharp: "npm:^0.34.3" shiki: "npm:^3.12.0" + socket.io: "npm:^4.8.1" strict-url-sanitise: "npm:^0.0.1" string-width: "npm:^7.2.0" striptags: "npm:^3.2.0" @@ -14214,6 +14232,16 @@ __metadata: languageName: node linkType: hard +"accepts@npm:~1.3.4": + version: 1.3.8 + resolution: "accepts@npm:1.3.8" + dependencies: + mime-types: "npm:~2.1.34" + negotiator: "npm:0.6.3" + checksum: 10c0/3a35c5f5586cfb9a21163ca47a5f77ac34fa8ceb5d17d2fa2c0d81f41cbd7f8c6fa52c77e2c039acc0f4d09e71abdc51144246900f6bef5e3c4b333f77d89362 + languageName: node + linkType: hard + "acorn-jsx@npm:^5.3.2": version: 5.3.2 resolution: "acorn-jsx@npm:5.3.2" @@ -14900,6 +14928,13 @@ __metadata: languageName: node linkType: hard +"base64id@npm:2.0.0, base64id@npm:~2.0.0": + version: 2.0.0 + resolution: "base64id@npm:2.0.0" + checksum: 10c0/6919efd237ed44b9988cbfc33eca6f173a10e810ce50292b271a1a421aac7748ef232a64d1e6032b08f19aae48dce6ee8f66c5ae2c9e5066c82b884861d4d453 + languageName: node + linkType: hard + "basic-ftp@npm:^5.0.2": version: 5.0.5 resolution: "basic-ftp@npm:5.0.5" @@ -16176,7 +16211,7 @@ __metadata: languageName: node linkType: hard -"cookie@npm:^0.7.1": +"cookie@npm:^0.7.1, cookie@npm:~0.7.2": version: 0.7.2 resolution: "cookie@npm:0.7.2" checksum: 10c0/9596e8ccdbf1a3a88ae02cf5ee80c1c50959423e1022e4e60b91dd87c622af1da309253d8abdb258fb5e3eacb4f08e579dc58b4897b8087574eee0fd35dfa5d2 @@ -16206,7 +16241,7 @@ __metadata: languageName: node linkType: hard -"cors@npm:^2.8.5": +"cors@npm:^2.8.5, cors@npm:~2.8.5": version: 2.8.5 resolution: "cors@npm:2.8.5" dependencies: @@ -16869,6 +16904,18 @@ __metadata: languageName: node linkType: hard +"debug@npm:~4.3.1, debug@npm:~4.3.2, debug@npm:~4.3.4": + version: 4.3.7 + resolution: "debug@npm:4.3.7" + dependencies: + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10c0/1471db19c3b06d485a622d62f65947a19a23fbd0dd73f7fd3eafb697eec5360cde447fb075919987899b1a2096e85d35d4eb5a4de09a57600ac9cf7e6c8e768b + languageName: node + linkType: hard + "decamelize@npm:1.2.0": version: 1.2.0 resolution: "decamelize@npm:1.2.0" @@ -17796,6 +17843,30 @@ __metadata: languageName: node linkType: hard +"engine.io-parser@npm:~5.2.1": + version: 5.2.3 + resolution: "engine.io-parser@npm:5.2.3" + checksum: 10c0/ed4900d8dbef470ab3839ccf3bfa79ee518ea8277c7f1f2759e8c22a48f64e687ea5e474291394d0c94f84054749fd93f3ef0acb51fa2f5f234cc9d9d8e7c536 + languageName: node + linkType: hard + +"engine.io@npm:~6.6.0": + version: 6.6.4 + resolution: "engine.io@npm:6.6.4" + dependencies: + "@types/cors": "npm:^2.8.12" + "@types/node": "npm:>=10.0.0" + accepts: "npm:~1.3.4" + base64id: "npm:2.0.0" + cookie: "npm:~0.7.2" + cors: "npm:~2.8.5" + debug: "npm:~4.3.1" + engine.io-parser: "npm:~5.2.1" + ws: "npm:~8.17.1" + checksum: 10c0/845761163f8ea7962c049df653b75dafb6b3693ad6f59809d4474751d7b0392cbf3dc2730b8a902ff93677a91fd28711d34ab29efd348a8a4b49c6b0724021ab + languageName: node + linkType: hard + "enhanced-resolve@npm:^5.18.3": version: 5.18.3 resolution: "enhanced-resolve@npm:5.18.3" @@ -23077,7 +23148,7 @@ __metadata: languageName: node linkType: hard -"mime-types@npm:^2.1.12, mime-types@npm:^2.1.35": +"mime-types@npm:^2.1.12, mime-types@npm:^2.1.35, mime-types@npm:~2.1.34": version: 2.1.35 resolution: "mime-types@npm:2.1.35" dependencies: @@ -23541,6 +23612,13 @@ __metadata: languageName: node linkType: hard +"negotiator@npm:0.6.3": + version: 0.6.3 + resolution: "negotiator@npm:0.6.3" + checksum: 10c0/3ec9fd413e7bf071c937ae60d572bc67155262068ed522cf4b3be5edbe6ddf67d095ec03a3a14ebf8fc8e95f8e1d61be4869db0dbb0de696f6b837358bd43fc2 + languageName: node + linkType: hard + "negotiator@npm:^0.6.3": version: 0.6.4 resolution: "negotiator@npm:0.6.4" @@ -25232,6 +25310,15 @@ __metadata: languageName: node linkType: hard +"qrcode.react@npm:^4.2.0": + version: 4.2.0 + resolution: "qrcode.react@npm:4.2.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10c0/68c691d130e5fda2f57cee505ed7aea840e7d02033100687b764601f9595e1116e34c13876628a93e1a5c2b85e4efc27d30b2fda72e2050c02f3e1c4e998d248 + languageName: node + linkType: hard + "qs@npm:^6.14.0": version: 6.14.0 resolution: "qs@npm:6.14.0" @@ -27499,6 +27586,41 @@ __metadata: languageName: node linkType: hard +"socket.io-adapter@npm:~2.5.2": + version: 2.5.5 + resolution: "socket.io-adapter@npm:2.5.5" + dependencies: + debug: "npm:~4.3.4" + ws: "npm:~8.17.1" + checksum: 10c0/04a5a2a9c4399d1b6597c2afc4492ab1e73430cc124ab02b09e948eabf341180b3866e2b61b5084cb899beb68a4db7c328c29bda5efb9207671b5cb0bc6de44e + languageName: node + linkType: hard + +"socket.io-parser@npm:~4.2.4": + version: 4.2.4 + resolution: "socket.io-parser@npm:4.2.4" + dependencies: + "@socket.io/component-emitter": "npm:~3.1.0" + debug: "npm:~4.3.1" + checksum: 10c0/9383b30358fde4a801ea4ec5e6860915c0389a091321f1c1f41506618b5cf7cd685d0a31c587467a0c4ee99ef98c2b99fb87911f9dfb329716c43b587f29ca48 + languageName: node + linkType: hard + +"socket.io@npm:^4.8.1": + version: 4.8.1 + resolution: "socket.io@npm:4.8.1" + dependencies: + accepts: "npm:~1.3.4" + base64id: "npm:~2.0.0" + cors: "npm:~2.8.5" + debug: "npm:~4.3.2" + engine.io: "npm:~6.6.0" + socket.io-adapter: "npm:~2.5.2" + socket.io-parser: "npm:~4.2.4" + checksum: 10c0/acf931a2bb235be96433b71da3d8addc63eeeaa8acabd33dc8d64e12287390a45f1e9f389a73cf7dc336961cd491679741b7a016048325c596835abbcc017ca9 + languageName: node + linkType: hard + "socks-proxy-agent@npm:^7.0.0": version: 7.0.0 resolution: "socks-proxy-agent@npm:7.0.0" @@ -28951,6 +29073,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~7.10.0": + version: 7.10.0 + resolution: "undici-types@npm:7.10.0" + checksum: 10c0/8b00ce50e235fe3cc601307f148b5e8fb427092ee3b23e8118ec0a5d7f68eca8cee468c8fc9f15cbb2cf2a3797945ebceb1cbd9732306a1d00e0a9b6afa0f635 + languageName: node + linkType: hard + "undici@npm:6.21.2": version: 6.21.2 resolution: "undici@npm:6.21.2" @@ -29987,6 +30116,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:~8.17.1": + version: 8.17.1 + resolution: "ws@npm:8.17.1" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10c0/f4a49064afae4500be772abdc2211c8518f39e1c959640457dcee15d4488628620625c783902a52af2dd02f68558da2868fd06e6fd0e67ebcd09e6881b1b5bfe + languageName: node + linkType: hard + "xlsx@https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz": version: 0.20.2 resolution: "xlsx@https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz" From 8da43ab794910f53799baa6ab028834e61fd4dab Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Fri, 31 Oct 2025 17:15:55 +0800 Subject: [PATCH 10/10] chore: update release notes for v1.7.0-beta.3 - Added new features including an enhanced tool permission system, plugin management, and support for various AI models. - Improved UI elements and agent creation processes. - Fixed multiple bugs related to session models, assistant activation, and various API integrations. - Updated version in package.json to v1.7.0-beta.3. --- electron-builder.yml | 140 ++++++++++++++++++++++++++++++------------- package.json | 2 +- 2 files changed, 99 insertions(+), 43 deletions(-) diff --git a/electron-builder.yml b/electron-builder.yml index 8e31ee7d9..ad90b3992 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -133,60 +133,116 @@ artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | - What's New in v1.7.0-beta.2 + What's New in v1.7.0-beta.3 New Features: - - Session Settings: Manage session-specific settings and model configurations independently - - Notes Full-Text Search: Search across all notes with match highlighting - - Built-in DiDi MCP Server: Integration with DiDi ride-hailing services (China only) - - Intel OV OCR: Hardware-accelerated OCR using Intel NPU - - Auto-start API Server: Automatically starts when agents exist + - Enhanced Tool Permission System: Real-time tool approval interface with improved UX + - Plugin Management System: Support for Claude Agent plugins (agents, commands, skills) + - Skill Tool: Add skill execution capabilities for agents + - Mobile App Data Restore: Support restoring data to mobile applications + - OpenMinerU Preprocessor: Knowledge base now supports open-source MinerU for document processing + - HuggingFace Provider: Added HuggingFace as AI provider + - Claude Haiku 4.5: Support for the latest Claude Haiku 4.5 model + - Ling Series Models: Added support for Ling-1T and related models + - Intel OVMS Painting: New painting provider using Intel OpenVINO Model Server + - Automatic Update Checks: Implement periodic update checking with configurable intervals + - HuggingChat Mini App: New mini app for HuggingChat integration Improvements: - - Agent model selection now requires explicit user choice - - Added Mistral AI provider support - - Added NewAPI generic provider support - - Improved navbar layout consistency across different modes - - Enhanced chat component responsiveness - - Better code block display on small screens - - Updated OVMS to 2025.3 official release - - Added Greek language support + - Agent Creation: New agents are now automatically activated upon creation + - Lazy Loading: Optimize page load performance with route lazy loading + - UI Enhancements: Improved agent item styling and layout consistency + - Navigation: Better navbar layout for fullscreen mode on macOS + - Settings Tab: Enhanced context slider consistency + - Backup Manager: Unified footer layout for local and S3 backup managers + - Menu System: Enhanced application menu with improved help section + - Proxy Rules: Comprehensive proxy bypass rule matching + - German Language: Added German language support + - MCP Confirmation: Added confirmation modal when activating protocol-installed MCP servers + - Translation: Enhanced translation script with concurrency and validation + - Electron & Vite: Updated to Electron 38 and Vite 4.0.1 + + Claude Code Tool Improvements: + - GlobTool: Now counts lines instead of files in output for better clarity + - ReadTool: Automatically removes system reminder tags from output + - TodoWriteTool: Improved rendering behavior + - Environment Variables: Updated model-related environment variable names Bug Fixes: - - Fixed GitHub Copilot gpt-5-codex streaming issues - - Fixed assistant creation failures - - Fixed translate auto-copy functionality - - Fixed miniapps external link opening - - Fixed message layout and overflow issues - - Fixed API key parsing to preserve spaces - - Fixed agent display in different navbar layouts + - Fixed session model not being used when sending messages + - Fixed tool approval UI and shared workspace plugin inconsistencies + - Fixed API server readiness notification to renderer + - Fixed grouped items not respecting saved tag order + - Fixed assistant/agent activation when creating new ones + - Fixed Dashscope Anthropic API host and migrated old configs + - Fixed Qwen3 thinking mode control for Ollama + - Fixed disappeared MCP button + - Fixed create assistant causing blank screen + - Fixed up-down button visibility in some cases + - Fixed hooks preventing save on composing enter key + - Fixed Azure GPT-image-1 and OpenRouter Gemini-image + - Fixed Silicon reasoning issues + - Fixed topic branch incomplete copy with two-pass ID mapping + - Fixed deep research model search context restrictions + - Fixed model capability checking logic + - Fixed reranker API error response capture + - Fixed right-click paste file content into inputbar + - Fixed minimax-m2 support in aiCore - v1.7.0-beta.2 新特性 + v1.7.0-beta.3 新特性 新功能: - - 会话设置:独立管理会话特定的设置和模型配置 - - 笔记全文搜索:跨所有笔记搜索并高亮匹配内容 - - 内置滴滴 MCP 服务器:集成滴滴打车服务(仅限中国地区) - - Intel OV OCR:使用 Intel NPU 的硬件加速 OCR - - 自动启动 API 服务器:当存在 Agent 时自动启动 + - 增强工具权限系统:实时工具审批界面,改进用户体验 + - 插件管理系统:支持 Claude Agent 插件(agents、commands、skills) + - 技能工具:为 Agent 添加技能执行能力 + - 移动应用数据恢复:支持将数据恢复到移动应用程序 + - OpenMinerU 预处理器:知识库现支持使用开源 MinerU 处理文档 + - HuggingFace 提供商:添加 HuggingFace 作为 AI 提供商 + - Claude Haiku 4.5:支持最新的 Claude Haiku 4.5 模型 + - Ling 系列模型:添加 Ling-1T 及相关模型支持 + - Intel OVMS 绘图:使用 Intel OpenVINO 模型服务器的新绘图提供商 + - 自动更新检查:实现可配置间隔的定期更新检查 + - HuggingChat 小程序:新增 HuggingChat 集成小程序 改进: - - Agent 模型选择现在需要用户显式选择 - - 添加 Mistral AI 提供商支持 - - 添加 NewAPI 通用提供商支持 - - 改进不同模式下的导航栏布局一致性 - - 增强聊天组件响应式设计 - - 优化小屏幕代码块显示 - - 更新 OVMS 至 2025.3 正式版 - - 添加希腊语支持 + - Agent 创建:新创建的 Agent 现在会自动激活 + - 懒加载:通过路由懒加载优化页面加载性能 + - UI 增强:改进 Agent 项目样式和布局一致性 + - 导航:改进 macOS 全屏模式下的导航栏布局 + - 设置选项卡:增强上下文滑块一致性 + - 备份管理器:统一本地和 S3 备份管理器的页脚布局 + - 菜单系统:增强应用菜单,改进帮助部分 + - 代理规则:全面的代理绕过规则匹配 + - 德语支持:添加德语语言支持 + - MCP 确认:添加激活协议安装的 MCP 服务器时的确认模态框 + - 翻译:增强翻译脚本的并发和验证功能 + - Electron & Vite:更新至 Electron 38 和 Vite 4.0.1 + + Claude Code 工具改进: + - GlobTool:现在计算行数而不是文件数,提供更清晰的输出 + - ReadTool:自动从输出中移除系统提醒标签 + - TodoWriteTool:改进渲染行为 + - 环境变量:更新模型相关的环境变量名称 问题修复: - - 修复 GitHub Copilot gpt-5-codex 流式传输问题 - - 修复助手创建失败 - - 修复翻译自动复制功能 - - 修复小程序外部链接打开 - - 修复消息布局和溢出问题 - - 修复 API 密钥解析以保留空格 - - 修复不同导航栏布局中的 Agent 显示 + - 修复发送消息时未使用会话模型 + - 修复工具审批 UI 和共享工作区插件不一致 + - 修复 API 服务器就绪通知到渲染器 + - 修复分组项目不遵守已保存标签顺序 + - 修复创建新的助手/Agent 时的激活问题 + - 修复 Dashscope Anthropic API 主机并迁移旧配置 + - 修复 Ollama 的 Qwen3 思考模式控制 + - 修复 MCP 按钮消失 + - 修复创建助手导致空白屏幕 + - 修复某些情况下上下按钮可见性 + - 修复钩子在输入法输入时阻止保存 + - 修复 Azure GPT-image-1 和 OpenRouter Gemini-image + - 修复 Silicon 推理问题 + - 修复主题分支不完整复制,采用两阶段 ID 映射 + - 修复深度研究模型搜索上下文限制 + - 修复模型能力检查逻辑 + - 修复 reranker API 错误响应捕获 + - 修复右键粘贴文件内容到输入栏 + - 修复 aiCore 中的 minimax-m2 支持 diff --git a/package.json b/package.json index e4b7e646d..0819ca99f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.7.0-beta.2", + "version": "1.7.0-beta.3", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js",