Files
gnoma/internal/plugin/manager.go
vikingowl d2d79d65da 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
2026-04-12 03:09:05 +02:00

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())
})
}