feat(m8): MCP client, tool replaceability, and plugin system
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
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
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())
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user