Files
MuyueWorkspace/internal/plugins/loader.go
Augustin cb525e6598
All checks were successful
Beta Release / beta (push) Successful in 5m9s
feat: RAG, memory, plugins, lessons, file editor, split panes, Markdown rendering, PWA + UI overhaul
Major additions:
- RAG pipeline (indexing, chunking, search) with sidebar upload button
- Memory system with CRUD API
- Plugins and lessons modules
- MCP discovery and MCP server
- Advanced skills (auto-create, conditional, improver)
- Agent browser/image support, delegate, sessions
- File editor with CodeMirror in split panes
- Markdown rendering via react-markdown + KaTeX + highlight.js
- Raw markdown toggle
- PWA manifest + service worker
- Extension UI redesign with new design tokens and studio-style chat
- Pipeline API for chat streaming
- Mobile responsive layout

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 21:01:08 +02:00

335 lines
7.9 KiB
Go

package plugins
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"github.com/muyue/muyue/internal/agent"
)
func DiscoverPlugins(paths []string) []*DiscoveredPlugin {
var plugins []*DiscoveredPlugin
for _, p := range paths {
expanded := expandPath(p)
info, err := os.Stat(expanded)
if err != nil {
continue
}
if info.IsDir() {
entries, err := os.ReadDir(expanded)
if err != nil {
continue
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
pluginDir := filepath.Join(expanded, entry.Name())
if dp := scanPluginDir(pluginDir); dp != nil {
plugins = append(plugins, dp)
}
}
}
}
return plugins
}
type DiscoveredPlugin struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
Valid bool `json:"valid"`
Error string `json:"error,omitempty"`
}
func scanPluginDir(dir string) *DiscoveredPlugin {
name := filepath.Base(dir)
dp := &DiscoveredPlugin{
Name: name,
Path: dir,
}
initPy := filepath.Join(dir, "__init__.py")
mainGo := filepath.Join(dir, "main.go")
manifest := filepath.Join(dir, "plugin.json")
if _, err := os.Stat(manifest); err == nil {
dp.Type = "manifest"
dp.Valid = true
return dp
}
if _, err := os.Stat(mainGo); err == nil {
dp.Type = "go"
dp.Valid = true
return dp
}
if _, err := os.Stat(initPy); err == nil {
dp.Type = "python"
dp.Valid = true
return dp
}
executables := []string{name, name + ".sh"}
for _, exe := range executables {
fullPath := filepath.Join(dir, exe)
if info, err := os.Stat(fullPath); err == nil && !info.IsDir() {
dp.Type = "executable"
dp.Valid = true
dp.Path = fullPath
return dp
}
}
return dp
}
type PluginManifest struct {
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description"`
Tools []ManifestTool `json:"tools,omitempty"`
Hooks []ManifestHook `json:"hooks,omitempty"`
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
}
type ManifestTool struct {
Name string `json:"name"`
Description string `json:"description"`
Params json.RawMessage `json:"parameters"`
}
type ManifestHook struct {
Type string `json:"type"`
}
func LoadManifest(path string) (*PluginManifest, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read manifest: %w", err)
}
var manifest PluginManifest
if err := json.Unmarshal(data, &manifest); err != nil {
return nil, fmt.Errorf("parse manifest: %w", err)
}
return &manifest, nil
}
func LoadExecutablePlugin(discovered *DiscoveredPlugin) (*Plugin, error) {
if !discovered.Valid {
return nil, fmt.Errorf("invalid plugin: %s", discovered.Name)
}
switch discovered.Type {
case "manifest":
return loadManifestPlugin(discovered)
case "executable":
return loadExecutableAsPlugin(discovered)
default:
return nil, fmt.Errorf("unsupported plugin type: %s", discovered.Type)
}
}
func loadManifestPlugin(dp *DiscoveredPlugin) (*Plugin, error) {
manifestPath := filepath.Join(dp.Path, "plugin.json")
manifest, err := LoadManifest(manifestPath)
if err != nil {
return nil, err
}
p := NewPlugin(manifest.Name, manifest.Version, manifest.Description)
for _, mt := range manifest.Tools {
handler := createExternalHandler(dp.Path, manifest)
td := &ToolDefinition{
Name: mt.Name,
Description: mt.Description,
Params: mt.Params,
Handler: handler,
}
p.AddTool(td)
}
return p, nil
}
func loadExecutableAsPlugin(dp *DiscoveredPlugin) (*Plugin, error) {
p := NewPlugin(dp.Name, "0.0.1", "Executable plugin: "+dp.Name)
paramsSchema, _ := json.Marshal(map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]string{"type": "string", "description": "Action to execute"},
"args": map[string]string{"type": "object", "description": "Arguments for the action"},
},
"required": []string{"action"},
})
td := &ToolDefinition{
Name: dp.Name,
Description: "External plugin tool: " + dp.Name,
Params: paramsSchema,
Handler: createScriptHandler(dp.Path),
}
p.AddTool(td)
return p, nil
}
func createExternalHandler(pluginDir string, manifest *PluginManifest) func(ctx context.Context, raw json.RawMessage) (agent.ToolResponse, error) {
return func(ctx context.Context, raw json.RawMessage) (agent.ToolResponse, error) {
if manifest.Command == "" {
return agent.TextErrorResponse(fmt.Sprintf("no command configured for plugin %s", manifest.Name)), nil
}
cmd := exec.CommandContext(ctx, manifest.Command, manifest.Args...)
cmd.Dir = pluginDir
cmd.Stdin = strings.NewReader(string(raw))
output, err := cmd.CombinedOutput()
if err != nil {
return agent.TextErrorResponse(fmt.Sprintf("plugin execution failed: %v\n%s", err, string(output))), nil
}
return agent.TextResponse(string(output)), nil
}
}
func createScriptHandler(scriptPath string) func(ctx context.Context, raw json.RawMessage) (agent.ToolResponse, error) {
return func(ctx context.Context, raw json.RawMessage) (agent.ToolResponse, error) {
cmd := exec.CommandContext(ctx, scriptPath)
cmd.Stdin = strings.NewReader(string(raw))
output, err := cmd.CombinedOutput()
if err != nil {
return agent.TextErrorResponse(fmt.Sprintf("script failed: %v\n%s", err, string(output))), nil
}
return agent.TextResponse(string(output)), nil
}
}
func DefaultPluginPaths() []string {
home, err := os.UserHomeDir()
if err != nil {
return nil
}
configDir, err := configDir()
if err != nil {
return []string{filepath.Join(home, ".muyue", "plugins")}
}
return []string{
filepath.Join(configDir, "plugins"),
filepath.Join(home, ".muyue", "plugins"),
}
}
func expandPath(p string) string {
if strings.HasPrefix(p, "~/") {
home, _ := os.UserHomeDir()
return filepath.Join(home, p[2:])
}
return p
}
func configDir() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(configDir, "muyue"), nil
}
func generatePluginSchema(v interface{}) (json.RawMessage, error) {
t := reflect.TypeOf(v)
if t == nil {
return json.RawMessage(`{"type":"object","properties":{}}`), nil
}
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return json.RawMessage(`{"type":"object","properties":{}}`), nil
}
props := make(map[string]interface{})
required := []string{}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if !field.IsExported() {
continue
}
jsonTag := field.Tag.Get("json")
if jsonTag == "-" {
continue
}
jsonName := field.Name
parts := strings.Split(jsonTag, ",")
if parts[0] != "" {
jsonName = parts[0]
}
omitempty := false
for _, part := range parts[1:] {
if part == "omitempty" {
omitempty = true
}
}
desc := field.Tag.Get("description")
prop := map[string]interface{}{"type": goTypeToJSON(field.Type)}
if desc != "" {
prop["description"] = desc
}
props[jsonName] = prop
if !omitempty {
required = append(required, jsonName)
}
}
schema := map[string]interface{}{
"type": "object",
"properties": props,
}
if len(required) > 0 {
schema["required"] = required
}
return json.Marshal(schema)
}
func goTypeToJSON(t reflect.Type) string {
switch t.Kind() {
case reflect.String:
return "string"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return "integer"
case reflect.Float32, reflect.Float64:
return "number"
case reflect.Bool:
return "boolean"
case reflect.Slice:
if t.Elem().Kind() == reflect.Uint8 {
return "string"
}
return "array"
case reflect.Map:
return "object"
default:
return "string"
}
}