All checks were successful
Stable Release / stable (push) Successful in 1m34s
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>
335 lines
7.9 KiB
Go
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"
|
|
}
|
|
}
|