Complete the remaining M8 extensibility deliverables:
- MCP client with JSON-RPC 2.0 over stdio transport, protocol
lifecycle (initialize/tools-list/tools-call), and process group
management for clean shutdown
- MCP tool adapter implementing tool.Tool with mcp__{server}__{tool}
naming convention and replace_default for swapping built-in tools
- MCP manager for multi-server orchestration with parallel startup,
tool discovery, and registry integration
- Plugin system with plugin.json manifest (name/version/capabilities),
directory-based discovery (global + project scopes with precedence),
loader that merges skills/hooks/MCP configs into existing registries,
and install/uninstall/list lifecycle manager
- Config additions: MCPServerConfig, PluginsSection with opt-in/opt-out
enabled/disabled resolution
- TUI /plugins command for listing installed plugins
- 54 tests across internal/mcp and internal/plugin packages
155 lines
3.3 KiB
Go
155 lines
3.3 KiB
Go
package plugin
|
|
|
|
import (
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
// PluginInfo is a lightweight summary for listing plugins.
|
|
type PluginInfo struct {
|
|
Name string
|
|
Version string
|
|
Scope string
|
|
Dir string
|
|
}
|
|
|
|
// Manager handles plugin install/uninstall lifecycle.
|
|
type Manager struct {
|
|
globalDir string
|
|
projectDir string
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// NewManager creates a plugin manager.
|
|
func NewManager(globalDir, projectDir string, logger *slog.Logger) *Manager {
|
|
return &Manager{
|
|
globalDir: globalDir,
|
|
projectDir: projectDir,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// Install copies a plugin from src to the target scope directory.
|
|
func (m *Manager) Install(src, scope string) error {
|
|
manifestPath := filepath.Join(src, "plugin.json")
|
|
data, err := os.ReadFile(manifestPath)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %v", ErrManifestNotFound, err)
|
|
}
|
|
|
|
manifest, err := ParseManifest(data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
targetDir := m.scopeDir(scope)
|
|
destDir := filepath.Join(targetDir, manifest.Name)
|
|
|
|
if _, err := os.Stat(destDir); err == nil {
|
|
return fmt.Errorf("%w: %q already exists at %s", ErrAlreadyInstalled, manifest.Name, destDir)
|
|
}
|
|
|
|
if err := copyDir(src, destDir); err != nil {
|
|
return fmt.Errorf("plugin install %q: %w", manifest.Name, err)
|
|
}
|
|
|
|
m.logger.Info("plugin installed", "name", manifest.Name, "version", manifest.Version, "scope", scope)
|
|
return nil
|
|
}
|
|
|
|
// Uninstall removes a plugin directory.
|
|
func (m *Manager) Uninstall(name, scope string) error {
|
|
targetDir := filepath.Join(m.scopeDir(scope), name)
|
|
|
|
if _, err := os.Stat(targetDir); os.IsNotExist(err) {
|
|
return fmt.Errorf("%w: %q not found in %s scope", ErrNotFound, name, scope)
|
|
}
|
|
|
|
if err := os.RemoveAll(targetDir); err != nil {
|
|
return fmt.Errorf("plugin uninstall %q: %w", name, err)
|
|
}
|
|
|
|
m.logger.Info("plugin uninstalled", "name", name, "scope", scope)
|
|
return nil
|
|
}
|
|
|
|
// List returns info about all installed plugins across both scopes.
|
|
func (m *Manager) List() ([]PluginInfo, error) {
|
|
var infos []PluginInfo
|
|
m.listDir(m.globalDir, "user", &infos)
|
|
m.listDir(m.projectDir, "project", &infos)
|
|
return infos, nil
|
|
}
|
|
|
|
func (m *Manager) scopeDir(scope string) string {
|
|
if scope == "project" {
|
|
return m.projectDir
|
|
}
|
|
return m.globalDir
|
|
}
|
|
|
|
func (m *Manager) listDir(dir, scope string, infos *[]PluginInfo) {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
|
|
pluginDir := filepath.Join(dir, entry.Name())
|
|
data, err := os.ReadFile(filepath.Join(pluginDir, "plugin.json"))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
manifest, err := ParseManifest(data)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
*infos = append(*infos, PluginInfo{
|
|
Name: manifest.Name,
|
|
Version: manifest.Version,
|
|
Scope: scope,
|
|
Dir: pluginDir,
|
|
})
|
|
}
|
|
}
|
|
|
|
// copyDir recursively copies a directory.
|
|
func copyDir(src, dst string) error {
|
|
return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
relPath, err := filepath.Rel(src, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
targetPath := filepath.Join(dst, relPath)
|
|
|
|
if d.IsDir() {
|
|
return os.MkdirAll(targetPath, 0o755)
|
|
}
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
info, err := d.Info()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return os.WriteFile(targetPath, data, info.Mode())
|
|
})
|
|
}
|