Files
cherry-studio/src/main/services/PluginService.ts
T
LiuVaayne 352ecbc506 feat: add plugin management system for Claude Agent (agents, commands, skills) (#10854)
*  feat: add claude-code-templates via git submodule with build-time copy

- Add git submodule for davila7/claude-code-templates
- Create scripts/copy-templates.js to copy components at build time
- Update package.json build script to include template copying
- Add resources/data/components/ to .gitignore (generated files)

Templates are now automatically synced from external repo to resources/data/components/
during build process, avoiding manual file copying.

To update templates: git submodule update --remote --merge

* fix: update target directory for copying Claude Code templates

*  feat: merge Anthropics skills into template sync

* 📝 docs: add agent plugins management implementation spec

Add comprehensive implementation plan for plugin management feature:
- Security validation and transactional operations
- Plugin browsing, installation, and management UI
- IPC handlers and PluginService architecture
- Metadata caching and database integration

*  feat: add plugin management backend infrastructure

Backend implementation for Claude Code plugin management:

- Add PluginService with security validation and caching
- Create IPC handlers for plugin operations (list, install, uninstall)
- Add markdown parser with safe YAML frontmatter parsing
- Extend AgentConfiguration schema with installed_plugins field
- Update preload bridge to expose plugin API to renderer
- Add plugin types (PluginMetadata, PluginError, PluginResult)

Features:
- Transactional install/uninstall with rollback
- Path traversal prevention and file validation
- 5-minute plugin list caching for performance
- SHA-256 content hashing for integrity checks
- Duplicate plugin handling (auto-replace)

Dependencies added:
- gray-matter: Markdown frontmatter parsing
- js-yaml: Safe YAML parsing with FAILSAFE_SCHEMA

*  feat: add plugin management UI and integration

Complete frontend implementation for Claude Code plugin management:

**React Hooks:**
- useAvailablePlugins: Fetch and cache available plugins
- useInstalledPlugins: List installed plugins with refresh
- usePluginActions: Install/uninstall with loading states

**UI Components (HeroUI):**
- PluginCard: Display plugin with install/uninstall actions
- CategoryFilter: Multi-select chip-based category filter
- InstalledPluginsList: Table view with uninstall confirmation
- PluginBrowser: Search, filter, pagination, responsive grid
- PluginSettings: Main container with Available/Installed tabs

**Integration:**
- Added "Plugins" tab to AgentSettingsPopup
- Full i18n support (English, Simplified Chinese, Traditional Chinese)
- Toast notifications for success/error states
- Loading skeletons and empty states

**Features:**
- Search plugins by name/description
- Filter by category and type (agents/commands)
- Pagination (12 items per page)
- Install/uninstall with confirmation dialogs
- Real-time plugin list updates
- Responsive grid layout (1-3 columns)

All code formatted with Biome and follows existing patterns.

* 🐛 fix: add missing plugin i18n keys at root level

Add plugin translation keys at root 'plugins.*' level to match component usage:
- Search and filter UI strings
- Pluralization support for result counts
- Empty state messages
- Action button labels
- Confirmation dialog text

Translations added for all three locales (en-US, zh-CN, zh-TW).

* 🐛 fix: use getResourcePath() utility for plugin directory resolution

Replace manual path calculation with getResourcePath() utility which correctly
handles both development and production environments. This fixes the issue where
plugins were not loading because __dirname was resolving to the wrong location.

Fixes:
- Plugins now load correctly in development mode
- Path resolution consistent with other resource loading in the app
- Removed unused 'app' import from electron

* 🎨 fix: improve plugin UI scrolling and category filter layout

Fixes two UI issues:

1. Enable scrolling for plugin list:
   - Changed overflow-hidden to overflow-y-auto on tab containers
   - Plugin grid now scrollable when content exceeds viewport

2. Make category filter more compact:
   - Added max-h-24 (96px) height limit to category chip container
   - Enabled vertical scrolling for category chips
   - Prevents category filter from taking too much vertical space

UI improvements enhance usability when browsing large plugin collections.

* 🎨 fix: ensure both agent and command badges have visible backgrounds

Changed Chip variant from 'flat' to 'solid' for plugin type badges.
This ensures both agent (primary) and command (secondary) badges display
with consistent, visible background colors instead of command badges
appearing as text-only.

*  feat: add plugin detail modal for viewing full plugin information

Add modal to display complete plugin details when clicking on a card:

Features:
- Click any plugin card to view full details in a modal
- Shows complete description (not truncated)
- Displays all metadata: version, author, tools, allowed_tools, tags
- Shows file info: filename, size, source path, install date
- Install/uninstall actions available in modal
- Hover effect on cards to indicate clickability
- Button clicks don't trigger card click (event.stopPropagation)

Components:
- New PluginDetailModal component with scrollable content
- Updated PluginCard to be clickable (isPressable)
- Updated PluginBrowser to manage modal state

UI improvements provide better plugin exploration and decision-making.

* 🐛 fix: render plugin detail modal above agent settings modal

Use React portal to render PluginDetailModal directly to document.body,
ensuring it appears above the agent settings modal instead of being
blocked by it.

Changes:
- Import createPortal from react-dom
- Wrap modal content in createPortal(modalContent, document.body)
- Add z-[9999] to modal wrapper for proper layering

Fixes modal visibility issue where plugin details were hidden behind
the parent agent settings modal.

*  feat: add plugin content viewing and editing in detail modal

- Added IPC channels for reading and writing plugin content
- Implemented readContent() and writeContent() methods in PluginService
- Added IPC handlers for content operations with proper error handling
- Exposed plugin content API through preload bridge
- Updated PluginDetailModal to fetch and display markdown content
- Added edit mode with textarea for modifying plugin content
- Implemented save/cancel functionality with optimistic UI updates
- Added agentId prop to component chain for write operations
- Updated AgentConfigurationSchema to include all plugin metadata fields
- Moved plugin types to shared @types for cross-process access
- Added validation and security checks for content read/write
- Updated content hash in DB after successful edits

* 🐛 fix: change event handler from onPress to onClick for uninstall and install buttons

* 📝 docs: update AI Assistant Guide to clarify proposal and commit guidelines

* 📝 docs: add skills support extension spec for agent plugins management

*  feat: add secure file operation utilities for skills plugin system

- Implement copyDirectoryRecursive() with security protections
- Implement deleteDirectoryRecursive() with path validation
- Implement getDirectorySize() for folder size calculation
- Add path traversal protection using isPathInside()
- Handle symlinks securely to prevent attacks
- Add recursion depth limits to prevent stack overflow
- Preserve file permissions during copy
- Handle race conditions and missing files gracefully
- Skip special files (pipes, sockets, devices)

Security features:
- Path validation against allowedBasePath boundary
- Symlink detection and skip to prevent circular loops
- Input validation for null/empty/relative paths
- Comprehensive error handling and logging

Updated spec status to "In Progress" and added implementation progress checklist.

*  feat: add skill type support and skill metadata parsing

Type System Updates (plugin.ts):
- Add PluginType export for 'agent' | 'command' | 'skill'
- Update PluginMetadataSchema to include 'skill' in type enum
- Update InstalledPluginSchema to support skill type
- Update all option interfaces to support skill type
- Add skills array to ListAvailablePluginsResult
- Document filename semantics differences between types

Markdown Parser Updates (markdownParser.ts):
- Implement parseSkillMetadata() function for SKILL.md parsing
- Add comprehensive input validation (absolute path check)
- Add robust error handling with specific PluginErrors
- Add try-catch around file operations and YAML parsing
- Add type validation for frontmatter data fields
- Add proper logging using loggerService
- Handle getDirectorySize() failures gracefully
- Document hash scope decision (SKILL.md only vs entire folder)
- Use FAILSAFE_SCHEMA for safe YAML parsing

Security improvements:
- Path validation to ensure absolute paths
- Differentiate ENOENT from permission errors
- Type validation for all frontmatter fields
- Safe YAML parsing to prevent deserialization attacks

Updated spec progress tracking.

*  feat: implement complete skill support in PluginService

Core Infrastructure:
- Add imports for parseSkillMetadata and file operation utilities
- Add PluginType to imports for type-safe handling

Skill-Specific Methods:
- sanitizeFolderName() - validates folder names (no dots allowed)
- scanSkillDirectory() - scans skills/ for skill folders
- installSkill() - copies folders with transaction/rollback
- uninstallSkill() - removes folders with transaction/rollback

Updated Methods for Skills Support:
- listAvailable() - now scans and returns skills array
- install() - branches on type to handle skills vs files
- uninstall() - branches on type for skill/file handling
- ensureClaudeDirectory() - handles 'skills' subdirectory
- listInstalled() - validates skill folders on filesystem
- writeContent() - updated signature to accept PluginType

Key Implementation Details:
- Skills use folder names WITHOUT extensions
- Agents/commands use filenames WITH .md extension
- Different sanitization rules for folders vs files
- Transaction pattern with rollback for all operations
- Comprehensive logging and error handling
- Maintains backward compatibility with existing code

Updated spec progress tracking.

*  feat: add skill support to frontend hooks and UI components

Frontend Hooks (usePlugins.ts):
- Add skills state to useAvailablePlugins hook
- Return skills array in hook result
- Update install() to accept 'skill' type
- Update uninstall() to accept 'skill' type

UI Components:
- PluginCard: Add 'skill' type badge with success color
- PluginBrowser: Add skills prop and include in plugin list
- PluginBrowser: Update type definitions to include 'skill'
- PluginBrowser: Include skills in tab filtering

Complete frontend integration for skills plugin type.
Updated spec progress tracking.

* ♻️ refactor: remove unused variable in installSkill method

* 📝 docs: mark implementation as complete with summary

Implementation Status: COMPLETE (11/12 tasks)

Completed:
-  File operation utilities with security protections
-  Skill metadata parsing with validation
-  Plugin type system updated to include 'skill'
-  PluginService skill methods (scan, install, uninstall)
-  PluginService updated for skill support
-  IPC handlers (no changes needed - already generic)
-  Frontend hooks updated for skills
-  UI components updated (PluginCard, PluginBrowser)
-  Build check passed with lint fixes

Deferred (non-blocking):
- ⏸️ Session integration - requires further investigation
  into session handler location and implementation

The core skills plugin system is fully implemented and functional.
Skills can be browsed, installed, and uninstalled through the UI.
All security requirements met with path validation and transaction
rollback. Code passes lint checks and follows project patterns.

* 🐛 fix: pass skills prop to PluginBrowser component

Fixed "skills is not iterable" error by:
- Destructuring skills from useAvailablePlugins hook
- Updating type annotations to include 'skill' type
- Passing skills prop to PluginBrowser component

This completes the missing UI wiring for skills support.

*  feat: add Skills tab to plugin browser

Added missing Skills tab to PluginBrowser component:
- Added Skills tab to type tabs
- Added translations for skills in all locales (en-us, zh-cn, zh-tw)
  - English: "Skills"
  - Simplified Chinese: "技能"
  - Traditional Chinese: "技能"

This completes the UI integration for the skills plugin type.

*  feat: add 'skill' type to AgentConfiguration and GetAgentSessionResponse schemas

* ⬆️ chore: upgrade @anthropic-ai/claude-agent-sdk to v0.1.25 with patch

- Updated from v0.1.1 to v0.1.25
- Applied fork/IPC patch to new version
- Removed old patch file
- All tests passing

* 🐛 fix: resolve linting and TypeScript type errors in build check

- Add external/** and resources/data/claude-code-plugins/** to lint ignore patterns
  to exclude git submodules and plugin templates from linting
- Fix TypeScript error handling in IPC handlers by properly typing caught errors
- Fix AgentConfiguration type mismatches by providing default values for
  permission_mode and max_turns when spreading configuration
- Replace control character regex with String.fromCharCode() to avoid ESLint
  no-control-regex rule in sanitization functions
- Fix markdownParser yaml.load return type by adding type assertion
- Add getPluginErrorMessage helper to properly extract error messages from
  PluginError discriminated union types

Main process TypeScript errors: Fixed (0 errors)
Linting errors: Fixed (0 errors from 4397)
Remaining: 4 renderer TypeScript errors in settings components

* ♻️ refactor: improve plugin error handling and reorganize i18n structure

* ⬆️ chore: update @anthropic-ai/claude-agent-sdk to include patch and additional dependencies

* 🗑️ chore: remove unused Claude code plugins and related configurations

- Deleted `.gitmodules` and associated submodules for `claude-code-templates` and `anthropics-skills`.
- Updated `.gitignore`, `.oxlintrc.json`, and `eslint.config.mjs` to exclude `claude-code-plugins`.
- Modified `package.json` to remove the build script dependency on copying templates.
- Adjusted `PluginService.ts` to handle plugin paths without relying on removed resources.

* format code

* delete

* delete

* fix(i18n): Auto update translations for PR #10854

*  feat: enhance PluginService and markdownParser with recursive skill directory search

- Added `findAllSkillDirectories` function to recursively locate directories containing `SKILL.md`.
- Updated `scanSkillDirectory` method in `PluginService` to utilize the new recursive search.
- Modified `PluginDetailModal` to append `/SKILL.md` to the source path for skill plugins.

* fix(i18n): Auto update translations for PR #10854

* remove specs

* update claude code plugins files

---------

Co-authored-by: suyao <sy20010504@gmail.com>
Co-authored-by: beyondkmp <beyondkmp@gmail.com>
Co-authored-by: GitHub Action <action@github.com>
2025-10-29 13:33:11 +08:00

1172 lines
35 KiB
TypeScript

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<PluginServiceConfig>) {
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<PluginServiceConfig>): PluginService {
if (!PluginService.instance) {
PluginService.instance = new PluginService(config)
}
return PluginService.instance
}
/**
* List all available plugins from resources directory (with caching)
*/
async listAvailable(): Promise<ListAvailablePluginsResult> {
const now = Date.now()
// Return cached data if still valid
if (this.availablePluginsCache && now - this.cacheTimestamp < this.config.cacheTimeout) {
logger.debug('Returning cached plugin list', {
cacheAge: now - this.cacheTimestamp
})
return this.availablePluginsCache
}
logger.info('Scanning available plugins')
// Scan all plugin types
const [agents, commands, skills] = await Promise.all([
this.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<PluginMetadata> {
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<void> {
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<InstalledPlugin[]> {
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<string> {
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<void> {
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<PluginMetadata[]> {
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<PluginMetadata[]> {
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<void> {
// 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<void> {
// 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<string> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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()