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" } }