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:
12
internal/plugin/errors.go
Normal file
12
internal/plugin/errors.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package plugin
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrManifestNotFound = errors.New("plugin: plugin.json not found")
|
||||
ErrManifestInvalid = errors.New("plugin: invalid manifest")
|
||||
ErrAlreadyInstalled = errors.New("plugin: already installed")
|
||||
ErrNotFound = errors.New("plugin: not found")
|
||||
ErrVersionMismatch = errors.New("plugin: gnoma version does not satisfy constraint")
|
||||
ErrPathTraversal = errors.New("plugin: path traversal detected")
|
||||
)
|
||||
156
internal/plugin/loader.go
Normal file
156
internal/plugin/loader.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"somegit.dev/Owlibou/gnoma/internal/config"
|
||||
)
|
||||
|
||||
// Plugin is a discovered, parsed plugin.
|
||||
type Plugin struct {
|
||||
Manifest Manifest
|
||||
Dir string // absolute path to plugin directory
|
||||
Scope string // "user" or "project"
|
||||
}
|
||||
|
||||
// SkillSource is a directory + source tag for skill.Registry.LoadDir.
|
||||
type SkillSource struct {
|
||||
Dir string
|
||||
Source string
|
||||
}
|
||||
|
||||
// LoadResult contains the merged capabilities from all loaded plugins.
|
||||
type LoadResult struct {
|
||||
Skills []SkillSource
|
||||
Hooks []config.HookConfig
|
||||
MCPServers []config.MCPServerConfig
|
||||
}
|
||||
|
||||
// Loader discovers and loads plugins from directories.
|
||||
type Loader struct {
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewLoader creates a plugin loader.
|
||||
func NewLoader(logger *slog.Logger) *Loader {
|
||||
return &Loader{logger: logger}
|
||||
}
|
||||
|
||||
// Discover scans global and project plugin directories, returning all valid plugins.
|
||||
// Project-scoped plugins override same-name global plugins.
|
||||
func (l *Loader) Discover(globalDir, projectDir string) ([]Plugin, error) {
|
||||
byName := make(map[string]Plugin)
|
||||
|
||||
// Global plugins first (user scope).
|
||||
l.scanDir(globalDir, "user", byName)
|
||||
|
||||
// Project plugins override global.
|
||||
l.scanDir(projectDir, "project", byName)
|
||||
|
||||
plugins := make([]Plugin, 0, len(byName))
|
||||
for _, p := range byName {
|
||||
plugins = append(plugins, p)
|
||||
}
|
||||
return plugins, nil
|
||||
}
|
||||
|
||||
// Load processes enabled plugins and extracts their capabilities.
|
||||
func (l *Loader) Load(plugins []Plugin, enabledSet map[string]bool) (LoadResult, error) {
|
||||
var result LoadResult
|
||||
|
||||
for _, p := range plugins {
|
||||
if !enabledSet[p.Manifest.Name] {
|
||||
l.logger.Debug("plugin disabled, skipping", "name", p.Manifest.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
l.logger.Debug("loading plugin", "name", p.Manifest.Name, "scope", p.Scope)
|
||||
|
||||
// Skills: resolve glob directories.
|
||||
for _, glob := range p.Manifest.Capabilities.Skills {
|
||||
// Use the directory portion of the glob as the skill source dir.
|
||||
skillDir := filepath.Join(p.Dir, filepath.Dir(glob))
|
||||
result.Skills = append(result.Skills, SkillSource{
|
||||
Dir: skillDir,
|
||||
Source: fmt.Sprintf("plugin:%s", p.Manifest.Name),
|
||||
})
|
||||
}
|
||||
|
||||
// Hooks: convert to config.HookConfig with resolved paths.
|
||||
for _, h := range p.Manifest.Capabilities.Hooks {
|
||||
execPath := h.Exec
|
||||
if execPath != "" && !filepath.IsAbs(execPath) {
|
||||
execPath = filepath.Join(p.Dir, execPath)
|
||||
}
|
||||
result.Hooks = append(result.Hooks, config.HookConfig{
|
||||
Name: h.Name,
|
||||
Event: h.Event,
|
||||
Type: h.Type,
|
||||
Exec: execPath,
|
||||
Timeout: h.Timeout,
|
||||
FailOpen: h.FailOpen,
|
||||
ToolPattern: h.ToolPattern,
|
||||
})
|
||||
}
|
||||
|
||||
// MCP servers: convert with resolved command paths.
|
||||
for _, s := range p.Manifest.Capabilities.MCPServers {
|
||||
cmd := s.Command
|
||||
if cmd != "" && !filepath.IsAbs(cmd) {
|
||||
cmd = filepath.Join(p.Dir, cmd)
|
||||
}
|
||||
result.MCPServers = append(result.MCPServers, config.MCPServerConfig{
|
||||
Name: s.Name,
|
||||
Command: cmd,
|
||||
Args: s.Args,
|
||||
Env: s.Env,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (l *Loader) scanDir(dir, scope string, byName map[string]Plugin) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
// Missing directory is fine (not all users have plugins).
|
||||
return
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
pluginDir := filepath.Join(dir, entry.Name())
|
||||
manifestPath := filepath.Join(pluginDir, "plugin.json")
|
||||
|
||||
data, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
l.logger.Debug("skipping plugin dir (no manifest)", "dir", pluginDir)
|
||||
continue
|
||||
}
|
||||
|
||||
manifest, err := ParseManifest(data)
|
||||
if err != nil {
|
||||
l.logger.Warn("skipping plugin (invalid manifest)", "dir", pluginDir, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
byName[manifest.Name] = Plugin{
|
||||
Manifest: *manifest,
|
||||
Dir: pluginDir,
|
||||
Scope: scope,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// marshalJSON is a thin wrapper for tests.
|
||||
func marshalJSON(v any) ([]byte, error) {
|
||||
return json.Marshal(v)
|
||||
}
|
||||
262
internal/plugin/loader_test.go
Normal file
262
internal/plugin/loader_test.go
Normal file
@@ -0,0 +1,262 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
}
|
||||
|
||||
// writePlugin creates a plugin directory with a plugin.json manifest.
|
||||
func writePlugin(t *testing.T, dir, name, version string, caps *Capabilities) {
|
||||
t.Helper()
|
||||
pluginDir := filepath.Join(dir, name)
|
||||
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
m := Manifest{Name: name, Version: version}
|
||||
if caps != nil {
|
||||
m.Capabilities = *caps
|
||||
}
|
||||
|
||||
data, _ := marshalManifest(m)
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), data, 0o644); err != nil {
|
||||
t.Fatalf("write manifest: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// writePluginWithSkill creates a plugin with a skill file.
|
||||
func writePluginWithSkill(t *testing.T, dir, pluginName, skillName, skillContent string) {
|
||||
t.Helper()
|
||||
pluginDir := filepath.Join(dir, pluginName)
|
||||
skillsDir := filepath.Join(pluginDir, "skills")
|
||||
os.MkdirAll(skillsDir, 0o755)
|
||||
|
||||
m := Manifest{
|
||||
Name: pluginName,
|
||||
Version: "1.0.0",
|
||||
Capabilities: Capabilities{
|
||||
Skills: []string{"skills/*.md"},
|
||||
},
|
||||
}
|
||||
data, _ := marshalManifest(m)
|
||||
os.WriteFile(filepath.Join(pluginDir, "plugin.json"), data, 0o644)
|
||||
os.WriteFile(filepath.Join(skillsDir, skillName+".md"), []byte(skillContent), 0o644)
|
||||
}
|
||||
|
||||
func marshalManifest(m Manifest) ([]byte, error) {
|
||||
return marshalJSON(m)
|
||||
}
|
||||
|
||||
func TestLoader_Discover_Empty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
loader := NewLoader(testLogger())
|
||||
|
||||
plugins, err := loader.Discover(filepath.Join(dir, "global"), filepath.Join(dir, "project"))
|
||||
if err != nil {
|
||||
t.Fatalf("Discover: %v", err)
|
||||
}
|
||||
if len(plugins) != 0 {
|
||||
t.Errorf("expected 0 plugins, got %d", len(plugins))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoader_Discover_GlobalPlugin(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
globalDir := filepath.Join(dir, "global")
|
||||
writePlugin(t, globalDir, "git-tools", "1.0.0", nil)
|
||||
|
||||
loader := NewLoader(testLogger())
|
||||
plugins, err := loader.Discover(globalDir, filepath.Join(dir, "project"))
|
||||
if err != nil {
|
||||
t.Fatalf("Discover: %v", err)
|
||||
}
|
||||
|
||||
if len(plugins) != 1 {
|
||||
t.Fatalf("expected 1 plugin, got %d", len(plugins))
|
||||
}
|
||||
if plugins[0].Manifest.Name != "git-tools" {
|
||||
t.Errorf("Name = %q, want %q", plugins[0].Manifest.Name, "git-tools")
|
||||
}
|
||||
if plugins[0].Scope != "user" {
|
||||
t.Errorf("Scope = %q, want %q", plugins[0].Scope, "user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoader_Discover_ProjectOverridesGlobal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
globalDir := filepath.Join(dir, "global")
|
||||
projectDir := filepath.Join(dir, "project")
|
||||
|
||||
writePlugin(t, globalDir, "shared", "1.0.0", nil)
|
||||
writePlugin(t, projectDir, "shared", "2.0.0", nil)
|
||||
|
||||
loader := NewLoader(testLogger())
|
||||
plugins, err := loader.Discover(globalDir, projectDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Discover: %v", err)
|
||||
}
|
||||
|
||||
if len(plugins) != 1 {
|
||||
t.Fatalf("expected 1 plugin (deduplicated), got %d", len(plugins))
|
||||
}
|
||||
if plugins[0].Manifest.Version != "2.0.0" {
|
||||
t.Errorf("Version = %q, want %q (project should override global)", plugins[0].Manifest.Version, "2.0.0")
|
||||
}
|
||||
if plugins[0].Scope != "project" {
|
||||
t.Errorf("Scope = %q, want %q", plugins[0].Scope, "project")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoader_Discover_SkipsInvalidManifest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
globalDir := filepath.Join(dir, "global")
|
||||
|
||||
// Write a valid plugin.
|
||||
writePlugin(t, globalDir, "good", "1.0.0", nil)
|
||||
|
||||
// Write an invalid plugin (bad JSON).
|
||||
badDir := filepath.Join(globalDir, "bad")
|
||||
os.MkdirAll(badDir, 0o755)
|
||||
os.WriteFile(filepath.Join(badDir, "plugin.json"), []byte(`{invalid`), 0o644)
|
||||
|
||||
loader := NewLoader(testLogger())
|
||||
plugins, err := loader.Discover(globalDir, filepath.Join(dir, "project"))
|
||||
if err != nil {
|
||||
t.Fatalf("Discover: %v", err)
|
||||
}
|
||||
|
||||
if len(plugins) != 1 {
|
||||
t.Fatalf("expected 1 plugin (skipping invalid), got %d", len(plugins))
|
||||
}
|
||||
if plugins[0].Manifest.Name != "good" {
|
||||
t.Errorf("Name = %q, want %q", plugins[0].Manifest.Name, "good")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoader_Load_AllEnabled(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
globalDir := filepath.Join(dir, "global")
|
||||
writePluginWithSkill(t, globalDir, "test-plugin", "my-skill", "---\nname: my-skill\n---\nHello")
|
||||
|
||||
loader := NewLoader(testLogger())
|
||||
plugins, _ := loader.Discover(globalDir, filepath.Join(dir, "project"))
|
||||
|
||||
enabledSet := map[string]bool{"test-plugin": true}
|
||||
result, err := loader.Load(plugins, enabledSet)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
|
||||
if len(result.Skills) != 1 {
|
||||
t.Fatalf("expected 1 skill source, got %d", len(result.Skills))
|
||||
}
|
||||
if result.Skills[0].Source != "plugin:test-plugin" {
|
||||
t.Errorf("Source = %q, want %q", result.Skills[0].Source, "plugin:test-plugin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoader_Load_DisabledPlugin(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
globalDir := filepath.Join(dir, "global")
|
||||
writePluginWithSkill(t, globalDir, "disabled-plugin", "skill", "---\nname: skill\n---\nHi")
|
||||
|
||||
loader := NewLoader(testLogger())
|
||||
plugins, _ := loader.Discover(globalDir, filepath.Join(dir, "project"))
|
||||
|
||||
// Plugin not in enabled set.
|
||||
result, err := loader.Load(plugins, map[string]bool{})
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
|
||||
if len(result.Skills) != 0 {
|
||||
t.Errorf("expected 0 skills for disabled plugin, got %d", len(result.Skills))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoader_Load_HooksConverted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
globalDir := filepath.Join(dir, "global")
|
||||
|
||||
caps := &Capabilities{
|
||||
Hooks: []HookSpec{
|
||||
{
|
||||
Name: "check",
|
||||
Event: "pre_tool_use",
|
||||
Type: "command",
|
||||
Exec: "scripts/check.sh",
|
||||
ToolPattern: "bash*",
|
||||
},
|
||||
},
|
||||
}
|
||||
writePlugin(t, globalDir, "hook-plugin", "1.0.0", caps)
|
||||
|
||||
loader := NewLoader(testLogger())
|
||||
plugins, _ := loader.Discover(globalDir, filepath.Join(dir, "project"))
|
||||
|
||||
result, err := loader.Load(plugins, map[string]bool{"hook-plugin": true})
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
|
||||
if len(result.Hooks) != 1 {
|
||||
t.Fatalf("expected 1 hook, got %d", len(result.Hooks))
|
||||
}
|
||||
h := result.Hooks[0]
|
||||
if h.Name != "check" {
|
||||
t.Errorf("Hook name = %q", h.Name)
|
||||
}
|
||||
if h.Event != "pre_tool_use" {
|
||||
t.Errorf("Hook event = %q", h.Event)
|
||||
}
|
||||
// Exec should be resolved to absolute path under plugin dir.
|
||||
pluginDir := filepath.Join(globalDir, "hook-plugin")
|
||||
wantExec := filepath.Join(pluginDir, "scripts/check.sh")
|
||||
if h.Exec != wantExec {
|
||||
t.Errorf("Hook exec = %q, want %q", h.Exec, wantExec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoader_Load_MCPServersConverted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
globalDir := filepath.Join(dir, "global")
|
||||
|
||||
caps := &Capabilities{
|
||||
MCPServers: []MCPServerSpec{
|
||||
{
|
||||
Name: "git",
|
||||
Command: "bin/mcp-git",
|
||||
Args: []string{"--verbose"},
|
||||
},
|
||||
},
|
||||
}
|
||||
writePlugin(t, globalDir, "mcp-plugin", "1.0.0", caps)
|
||||
|
||||
loader := NewLoader(testLogger())
|
||||
plugins, _ := loader.Discover(globalDir, filepath.Join(dir, "project"))
|
||||
|
||||
result, err := loader.Load(plugins, map[string]bool{"mcp-plugin": true})
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
|
||||
if len(result.MCPServers) != 1 {
|
||||
t.Fatalf("expected 1 MCP server, got %d", len(result.MCPServers))
|
||||
}
|
||||
s := result.MCPServers[0]
|
||||
if s.Name != "git" {
|
||||
t.Errorf("Name = %q", s.Name)
|
||||
}
|
||||
// Command should be absolute path.
|
||||
pluginDir := filepath.Join(globalDir, "mcp-plugin")
|
||||
wantCmd := filepath.Join(pluginDir, "bin/mcp-git")
|
||||
if s.Command != wantCmd {
|
||||
t.Errorf("Command = %q, want %q", s.Command, wantCmd)
|
||||
}
|
||||
}
|
||||
154
internal/plugin/manager.go
Normal file
154
internal/plugin/manager.go
Normal file
@@ -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())
|
||||
})
|
||||
}
|
||||
160
internal/plugin/manager_test.go
Normal file
160
internal/plugin/manager_test.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestManager_Install(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
globalDir := filepath.Join(dir, "global")
|
||||
projectDir := filepath.Join(dir, "project")
|
||||
os.MkdirAll(globalDir, 0o755)
|
||||
os.MkdirAll(projectDir, 0o755)
|
||||
|
||||
// Create a source plugin directory.
|
||||
srcDir := filepath.Join(dir, "src", "my-plugin")
|
||||
os.MkdirAll(srcDir, 0o755)
|
||||
m := Manifest{Name: "my-plugin", Version: "1.0.0", Description: "Test plugin"}
|
||||
data, _ := marshalJSON(m)
|
||||
os.WriteFile(filepath.Join(srcDir, "plugin.json"), data, 0o644)
|
||||
|
||||
mgr := NewManager(globalDir, projectDir, testLogger())
|
||||
|
||||
// Install to user scope.
|
||||
if err := mgr.Install(srcDir, "user"); err != nil {
|
||||
t.Fatalf("Install: %v", err)
|
||||
}
|
||||
|
||||
// Verify the plugin was copied.
|
||||
installed := filepath.Join(globalDir, "my-plugin", "plugin.json")
|
||||
if _, err := os.Stat(installed); err != nil {
|
||||
t.Errorf("installed manifest not found: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_Install_ProjectScope(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
globalDir := filepath.Join(dir, "global")
|
||||
projectDir := filepath.Join(dir, "project")
|
||||
os.MkdirAll(globalDir, 0o755)
|
||||
os.MkdirAll(projectDir, 0o755)
|
||||
|
||||
srcDir := filepath.Join(dir, "src", "proj-plugin")
|
||||
os.MkdirAll(srcDir, 0o755)
|
||||
m := Manifest{Name: "proj-plugin", Version: "1.0.0"}
|
||||
data, _ := marshalJSON(m)
|
||||
os.WriteFile(filepath.Join(srcDir, "plugin.json"), data, 0o644)
|
||||
|
||||
mgr := NewManager(globalDir, projectDir, testLogger())
|
||||
|
||||
if err := mgr.Install(srcDir, "project"); err != nil {
|
||||
t.Fatalf("Install: %v", err)
|
||||
}
|
||||
|
||||
installed := filepath.Join(projectDir, "proj-plugin", "plugin.json")
|
||||
if _, err := os.Stat(installed); err != nil {
|
||||
t.Errorf("installed manifest not found: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_Install_AlreadyInstalled(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
globalDir := filepath.Join(dir, "global")
|
||||
os.MkdirAll(globalDir, 0o755)
|
||||
|
||||
srcDir := filepath.Join(dir, "src", "dup")
|
||||
os.MkdirAll(srcDir, 0o755)
|
||||
m := Manifest{Name: "dup", Version: "1.0.0"}
|
||||
data, _ := marshalJSON(m)
|
||||
os.WriteFile(filepath.Join(srcDir, "plugin.json"), data, 0o644)
|
||||
|
||||
mgr := NewManager(globalDir, filepath.Join(dir, "project"), testLogger())
|
||||
|
||||
// First install.
|
||||
mgr.Install(srcDir, "user")
|
||||
|
||||
// Second install should fail.
|
||||
err := mgr.Install(srcDir, "user")
|
||||
if err == nil {
|
||||
t.Error("expected ErrAlreadyInstalled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_Install_NoManifest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
globalDir := filepath.Join(dir, "global")
|
||||
os.MkdirAll(globalDir, 0o755)
|
||||
|
||||
srcDir := filepath.Join(dir, "src", "empty")
|
||||
os.MkdirAll(srcDir, 0o755)
|
||||
|
||||
mgr := NewManager(globalDir, filepath.Join(dir, "project"), testLogger())
|
||||
err := mgr.Install(srcDir, "user")
|
||||
if err == nil {
|
||||
t.Error("expected error for missing manifest")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_Uninstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
globalDir := filepath.Join(dir, "global")
|
||||
os.MkdirAll(globalDir, 0o755)
|
||||
|
||||
// Pre-install a plugin.
|
||||
pluginDir := filepath.Join(globalDir, "to-remove")
|
||||
os.MkdirAll(pluginDir, 0o755)
|
||||
m := Manifest{Name: "to-remove", Version: "1.0.0"}
|
||||
data, _ := marshalJSON(m)
|
||||
os.WriteFile(filepath.Join(pluginDir, "plugin.json"), data, 0o644)
|
||||
|
||||
mgr := NewManager(globalDir, filepath.Join(dir, "project"), testLogger())
|
||||
|
||||
if err := mgr.Uninstall("to-remove", "user"); err != nil {
|
||||
t.Fatalf("Uninstall: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(pluginDir); !os.IsNotExist(err) {
|
||||
t.Error("plugin directory should be removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_Uninstall_NotFound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mgr := NewManager(filepath.Join(dir, "global"), filepath.Join(dir, "project"), testLogger())
|
||||
|
||||
err := mgr.Uninstall("nonexistent", "user")
|
||||
if err == nil {
|
||||
t.Error("expected ErrNotFound")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_List(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
globalDir := filepath.Join(dir, "global")
|
||||
projectDir := filepath.Join(dir, "project")
|
||||
|
||||
writePlugin(t, globalDir, "global-plugin", "1.0.0", nil)
|
||||
writePlugin(t, projectDir, "project-plugin", "2.0.0", nil)
|
||||
|
||||
mgr := NewManager(globalDir, projectDir, testLogger())
|
||||
|
||||
infos, err := mgr.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
|
||||
if len(infos) != 2 {
|
||||
t.Fatalf("expected 2 plugins, got %d", len(infos))
|
||||
}
|
||||
|
||||
// Check that both scopes are represented.
|
||||
scopes := map[string]bool{}
|
||||
for _, info := range infos {
|
||||
scopes[info.Scope] = true
|
||||
}
|
||||
if !scopes["user"] || !scopes["project"] {
|
||||
t.Errorf("expected both scopes, got %v", scopes)
|
||||
}
|
||||
}
|
||||
126
internal/plugin/manifest.go
Normal file
126
internal/plugin/manifest.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var namePattern = regexp.MustCompile(`^[a-z][a-z0-9_-]*$`)
|
||||
|
||||
// Manifest describes a plugin package.
|
||||
type Manifest struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Author string `json:"author"`
|
||||
License string `json:"license"`
|
||||
GnomaVersion string `json:"gnoma_version"`
|
||||
Capabilities Capabilities `json:"capabilities"`
|
||||
}
|
||||
|
||||
// Capabilities declares what a plugin provides.
|
||||
type Capabilities struct {
|
||||
Skills []string `json:"skills"`
|
||||
Hooks []HookSpec `json:"hooks"`
|
||||
MCPServers []MCPServerSpec `json:"mcp_servers"`
|
||||
}
|
||||
|
||||
// HookSpec defines a hook within a plugin manifest.
|
||||
type HookSpec struct {
|
||||
Name string `json:"name"`
|
||||
Event string `json:"event"`
|
||||
Type string `json:"type"`
|
||||
Exec string `json:"exec"`
|
||||
Timeout string `json:"timeout"`
|
||||
FailOpen bool `json:"fail_open"`
|
||||
ToolPattern string `json:"tool_pattern"`
|
||||
}
|
||||
|
||||
// MCPServerSpec defines an MCP server within a plugin manifest.
|
||||
type MCPServerSpec struct {
|
||||
Name string `json:"name"`
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
Env map[string]string `json:"env"`
|
||||
}
|
||||
|
||||
// ParseManifest parses and validates a plugin.json file.
|
||||
func ParseManifest(data []byte) (*Manifest, error) {
|
||||
var m Manifest
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrManifestInvalid, err)
|
||||
}
|
||||
if err := m.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// Validate checks manifest fields for correctness and safety.
|
||||
func (m *Manifest) Validate() error {
|
||||
if m.Name == "" {
|
||||
return fmt.Errorf("%w: name is required", ErrManifestInvalid)
|
||||
}
|
||||
if !namePattern.MatchString(m.Name) {
|
||||
return fmt.Errorf("%w: name %q must match %s", ErrManifestInvalid, m.Name, namePattern)
|
||||
}
|
||||
if m.Version == "" {
|
||||
return fmt.Errorf("%w: version is required", ErrManifestInvalid)
|
||||
}
|
||||
if !validSemver(m.Version) {
|
||||
return fmt.Errorf("%w: version %q is not valid semver (expected major.minor.patch)", ErrManifestInvalid, m.Version)
|
||||
}
|
||||
|
||||
for _, glob := range m.Capabilities.Skills {
|
||||
if err := checkSafePath(glob); err != nil {
|
||||
return fmt.Errorf("%w: skill glob %q: %v", ErrManifestInvalid, glob, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, h := range m.Capabilities.Hooks {
|
||||
if h.Exec != "" {
|
||||
if err := checkSafePath(h.Exec); err != nil {
|
||||
return fmt.Errorf("%w: hook %q exec: %v", ErrManifestInvalid, h.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, s := range m.Capabilities.MCPServers {
|
||||
if s.Command != "" {
|
||||
if err := checkSafePath(s.Command); err != nil {
|
||||
return fmt.Errorf("%w: mcp_server %q command: %v", ErrManifestInvalid, s.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkSafePath rejects absolute paths and path traversal.
|
||||
func checkSafePath(p string) error {
|
||||
if filepath.IsAbs(p) {
|
||||
return fmt.Errorf("%w: absolute path not allowed", ErrPathTraversal)
|
||||
}
|
||||
if strings.Contains(p, "..") {
|
||||
return fmt.Errorf("%w: path traversal not allowed", ErrPathTraversal)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validSemver checks for strict major.minor.patch format.
|
||||
func validSemver(v string) bool {
|
||||
parts := strings.Split(v, ".")
|
||||
if len(parts) != 3 {
|
||||
return false
|
||||
}
|
||||
for _, p := range parts {
|
||||
if _, err := strconv.Atoi(p); err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
207
internal/plugin/manifest_test.go
Normal file
207
internal/plugin/manifest_test.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseManifest_Valid(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"name": "git-tools",
|
||||
"version": "1.0.0",
|
||||
"description": "Git integration for gnoma",
|
||||
"author": "vikingowl",
|
||||
"capabilities": {
|
||||
"skills": ["skills/*.md"],
|
||||
"hooks": [
|
||||
{
|
||||
"name": "lint-before-commit",
|
||||
"event": "pre_tool_use",
|
||||
"type": "command",
|
||||
"exec": "scripts/lint.sh",
|
||||
"tool_pattern": "bash*"
|
||||
}
|
||||
],
|
||||
"mcp_servers": [
|
||||
{
|
||||
"name": "git",
|
||||
"command": "bin/mcp-git",
|
||||
"args": ["--verbose"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
|
||||
m, err := ParseManifest(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseManifest: %v", err)
|
||||
}
|
||||
|
||||
if m.Name != "git-tools" {
|
||||
t.Errorf("Name = %q, want %q", m.Name, "git-tools")
|
||||
}
|
||||
if m.Version != "1.0.0" {
|
||||
t.Errorf("Version = %q, want %q", m.Version, "1.0.0")
|
||||
}
|
||||
if len(m.Capabilities.Skills) != 1 {
|
||||
t.Errorf("Skills count = %d, want 1", len(m.Capabilities.Skills))
|
||||
}
|
||||
if len(m.Capabilities.Hooks) != 1 {
|
||||
t.Errorf("Hooks count = %d, want 1", len(m.Capabilities.Hooks))
|
||||
}
|
||||
if len(m.Capabilities.MCPServers) != 1 {
|
||||
t.Errorf("MCPServers count = %d, want 1", len(m.Capabilities.MCPServers))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManifest_Minimal(t *testing.T) {
|
||||
data := []byte(`{"name": "minimal", "version": "0.1.0"}`)
|
||||
m, err := ParseManifest(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseManifest: %v", err)
|
||||
}
|
||||
if m.Name != "minimal" {
|
||||
t.Errorf("Name = %q", m.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManifest_InvalidJSON(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`not json`))
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifest_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
m Manifest
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
m: Manifest{Name: "my-plugin", Version: "1.0.0"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty name",
|
||||
m: Manifest{Version: "1.0.0"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid name uppercase",
|
||||
m: Manifest{Name: "MyPlugin", Version: "1.0.0"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid name starts with number",
|
||||
m: Manifest{Name: "1plugin", Version: "1.0.0"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty version",
|
||||
m: Manifest{Name: "my-plugin"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid version",
|
||||
m: Manifest{Name: "my-plugin", Version: "not-semver"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "skill glob path traversal",
|
||||
m: Manifest{
|
||||
Name: "bad",
|
||||
Version: "1.0.0",
|
||||
Capabilities: Capabilities{
|
||||
Skills: []string{"../../../etc/passwd"},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "skill glob absolute path",
|
||||
m: Manifest{
|
||||
Name: "bad",
|
||||
Version: "1.0.0",
|
||||
Capabilities: Capabilities{
|
||||
Skills: []string{"/etc/passwd"},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "hook exec path traversal",
|
||||
m: Manifest{
|
||||
Name: "bad",
|
||||
Version: "1.0.0",
|
||||
Capabilities: Capabilities{
|
||||
Hooks: []HookSpec{{
|
||||
Name: "h",
|
||||
Event: "pre_tool_use",
|
||||
Type: "command",
|
||||
Exec: "../../../bin/evil",
|
||||
}},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "mcp command path traversal",
|
||||
m: Manifest{
|
||||
Name: "bad",
|
||||
Version: "1.0.0",
|
||||
Capabilities: Capabilities{
|
||||
MCPServers: []MCPServerSpec{{
|
||||
Name: "evil",
|
||||
Command: "../../../bin/evil",
|
||||
}},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "valid name with hyphens and numbers",
|
||||
m: Manifest{Name: "my-plugin-2", Version: "0.1.0"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid name with underscores",
|
||||
m: Manifest{Name: "my_plugin", Version: "0.1.0"},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.m.Validate()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidSemver(t *testing.T) {
|
||||
tests := []struct {
|
||||
v string
|
||||
want bool
|
||||
}{
|
||||
{"1.0.0", true},
|
||||
{"0.1.0", true},
|
||||
{"12.34.56", true},
|
||||
{"1.0", false},
|
||||
{"1", false},
|
||||
{"v1.0.0", false},
|
||||
{"1.0.0-beta", false}, // strict semver only for v1
|
||||
{"", false},
|
||||
{"not-a-version", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.v, func(t *testing.T) {
|
||||
if got := validSemver(tt.v); got != tt.want {
|
||||
t.Errorf("validSemver(%q) = %v, want %v", tt.v, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user