diff --git a/README.md b/README.md index 1d335e1..deef033 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ Triple 数据结构新增字段: func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageRegistrar, regAPI APIRegistrar, regOutput OutputChannelRegistrar) *PluginSDK ``` -插件开发者只需实现 `Plugin` 接口并导出 `NewPlugin()` 入口函数。 +插件开发者只需实现 `Plugin` 接口并导出 `NewPluginFactory()` 入口函数。 ## plugindev 工具链 @@ -125,13 +125,25 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg | 命令 | 说明 | |------|------| -| `plugindev init` | 初始化插件项目(生成 plg.json、入口模板) | -| `plugindev build` | 构建插件,输出 .hmap 包 | -| `plugindev clean` | 清理构建产物 | -| `plugindev debug` | 本地调试模式运行插件 | +| `plugindev init [--lua]` | 初始化插件项目(生成 plg.json、plugin.go 或 main.lua、go.mod、README.md) | +| `plugindev build [flags]` | 编译并打包为 `.hmap` 包(支持跨平台编译和 bundle 模式) | +| `plugindev clean` | 清理 `build/`、`dist/` 目录及生成文件(plugin.json、z_bridge_gen.go) | +| `plugindev debug [dir]` | 通过 Yaegi Go 解释器加载插件源码,启动交互式 REPL 调试 | +| `plugindev sdk ` | SDK 版本管理(子命令:list/install/use/path/current/latest) | 支持 **Go** 和 **Lua** 两种插件语言。 +### build 命令 flags + +| Flag | 说明 | +|------|------| +| `--outdir ` | 输出目录(默认 `dist`,可覆盖 plg.json 中的 `outdir`) | +| `--target ` | 构建目标(如 `linux/amd64`),可重复指定(追加到 plg.json 中的 targets) | +| `--bundle` | 强制 bundle 模式(同时编译 linux/amd64, darwin/amd64, windows/amd64) | +| `--no-bundle` | 关闭 bundle 模式,仅按 targets 逐个编译 | +| `--sdk-path ` | 指定 SDK 源码路径(覆盖 plg.json 中的 `sdk_path`) | +| `--replace ` / `-R` | Go 模块替换(追加到 plg.json 中的 replaces),`from` 为模块路径,`to` 为本地路径 | + ### plg.json 清单格式 ```json @@ -164,13 +176,15 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg | `version` | string | 版本号 | | `description` | string | 插件描述 | | `author` | string | 作者 | -| `entry` | string | 入口文件(`plugin.so` / `main.lua`) | +| `entry` | string | 入口文件(`plugin.so` / `plugin.dll` / `main.lua`) | | `tags` | string[] | 标签 | -| `targets` | string | 构建目标,逗号分隔(如 `linux/amd64,windows/amd64`) | +| `targets` | string | 构建目标,逗号分隔(如 `linux/amd64,windows/amd64`,Lua 插件为 `lua`) | | `outdir` | string | 输出目录(默认 `dist`) | -| `bundle` | bool | 是否 bundle 模式(同时编译多平台) | +| `bundle` | bool | 是否 bundle 模式(同时编译多平台,默认 `true`) | +| `sdk_path` | string | SDK 源码路径(覆盖自动检测的 SDK 路径) | +| `go_version` | string | Go 版本(如 `1.21`,默认从 SDK 的 go.mod 读取) | | `replaces` | object | Go 模块替换,key=模块路径,value=本地路径 | -| `source_dirs` | string[] | 额外源码搜索路径(编译时自动导入) | +| `source_dirs` | string[] | 额外源码搜索路径(编译时自动导入,用于引入 `thirdpart/` 外部的共享代码) | ### .hmap 包格式 @@ -179,10 +193,34 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg - `plugin.json` — 插件元数据 - `plugin.so` — Go 编译产物(Linux) - `plugin.dll` — Go 编译产物(Windows) +- `plugin.dylib` — Go 编译产物(macOS,bundle 模式) - `main.lua` — Lua 插件入口(Lua 插件时) ## 插件生命周期 +### 入口函数 + +插件必须导出 `NewPluginFactory` 入口函数(Go)或 `start()` 函数(Lua): + +**Go 插件** — 实现 `Plugin` 接口并导出工厂函数: + +```go +func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { + return &Plugin{name: name}, nil +} +``` + +该函数由内核在加载插件时调用,`name` 为插件名,`config` 为 `skill.json` 中的配置(如有)。 + +**Lua 插件** — 返回包含 `start(sdk)` 和 `stop()` 方法的 table: + +```lua +local plugin = { name = "my-plugin" } +function plugin.start(sdk) -- 注册工具等 end +function plugin.stop() end +return plugin +``` + ### 启动与停止 - `Start(sdk *PluginSDK) error` — 插件启动,接收 SDK 实例 @@ -214,14 +252,19 @@ enabled := sdk.AutoRestart() | 插件 | 说明 | |------|------| | a2a | Agent-to-Agent 协议通信 | +| ai_image | AI 图片生成 | | bili | Bilibili 视频下载 | -| browser | 网络搜索、网页抓取、浏览器渲染(合并自 web/webfetch) | +| browser | 网络搜索、网页抓取、浏览器渲染 | +| calendar | 日历管理 | | editdoc | 文档编辑 | | files | 文件管理 | -| memo | 备忘录/记忆 | +| memo | 备忘录 | +| music | 音乐播放 | | ocr | 光学字符识别 | -| qq | QQ 消息集成 | +| qq | QQ 消息集成(NapCat webhook,15 个工具) | +| rss | RSS 订阅 | | sanitizer | 内容清洗/安全过滤 | +| weather | 天气查询(wttr.in) | ## 构建与安装 diff --git a/go.mod b/go.mod index 665374d..22645b1 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module gitcode.com/JianFeeeee/homeagent-sdk -go 1.25.0 +go 1.21.0 diff --git a/tools/plugindev/cmd_build.go b/tools/plugindev/cmd_build.go index f99eb3b..f6b921e 100644 --- a/tools/plugindev/cmd_build.go +++ b/tools/plugindev/cmd_build.go @@ -284,22 +284,29 @@ func ensureGoMod(plg *PlgConfig, sdkPath string) { return } - // 检查是否已有 replace 指令 absSDK, _ := filepath.Abs(sdkPath) absSDK = strings.ReplaceAll(absSDK, "\\", "/") + + // Remove any existing replace line for this module (even if path differs) + var keep []string + replaceLine := fmt.Sprintf("replace %s => %s", sdkModule, absSDK) + alreadyExists := false for _, line := range lines { - if strings.Contains(line, "replace") && strings.Contains(line, sdkModule) { + if strings.HasPrefix(strings.TrimSpace(line), "replace ") && + strings.Contains(line, sdkModule) { parts := strings.Fields(line) if len(parts) >= 3 && strings.ReplaceAll(parts[2], "\\", "/") == absSDK { - return // 已存在且路径正确 + alreadyExists = true } + continue // strip any existing replace for this module } + keep = append(keep, line) } - - // 追加 replace 指令 - replaceLine := fmt.Sprintf("replace %s => %s", sdkModule, absSDK) - newData := string(data) + "\n" + replaceLine + "\n" - if err := os.WriteFile(gomodPath, []byte(newData), 0644); err != nil { + if alreadyExists { + return + } + keep = append(keep, replaceLine, "") + if err := os.WriteFile(gomodPath, []byte(strings.Join(keep, "\n")), 0644); err != nil { fmt.Printf(" warn: update go.mod replace: %v\n", err) } } @@ -387,7 +394,7 @@ func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) { // Auto-generate C ABI bridge (all platforms use c-shared) bridgeCleanup := generateBridge(cfg.goos) - _ = bridgeCleanup // DISABLED cleanup for debug + defer bridgeCleanup() // Auto-link thirdpart/ contents + source_dirs + replace targets thirdpartCleanup := linkThirdpart(plg, target) @@ -566,33 +573,6 @@ func detectWindowsCC() string { // stripIncludeGuard strips preprocessor guards and C++ comments from a C header, // since these can confuse cgo's type resolution. -func stripIncludeGuard(header string) string { - lines := strings.Split(header, "\n") - var out []string - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if trimmed == "#ifndef HOMEAGENT_CABI_H" || trimmed == "#define HOMEAGENT_CABI_H" { - continue - } - if trimmed == "#endif" || strings.HasPrefix(trimmed, "#endif") { - continue - } - if trimmed == "#ifdef __cplusplus" || trimmed == "extern \"C\" {" || trimmed == "}" { - continue - } - // Strip C++-style comments (cgo parser may not handle them in /* */ blocks) - if idx := strings.Index(line, "//"); idx >= 0 { - line = line[:idx] - } - cleaned := strings.TrimSpace(line) - if cleaned == "" { - continue - } - out = append(out, line) - } - return strings.Join(out, "\n") -} - // generateBridge generates the C ABI bridge files for non-Lua builds. // Returns a cleanup function to remove generated files. func generateBridge(goos string) func() { @@ -662,7 +642,11 @@ func linkThirdpart(plg *PlgConfig, target string) func() { dirs = append(dirs, "thirdpart") } dirs = append(dirs, plg.SourceDirs...) - for _, to := range plg.Replaces { + for _, r := range plg.ReplacesToSlice() { + _, to, found := strings.Cut(r, "=") + if !found { + continue + } if abs, err := filepath.Abs(to); err == nil { if info, err := os.Stat(abs); err == nil && info.IsDir() { dirs = append(dirs, abs) @@ -699,25 +683,21 @@ func linkThirdpart(plg *PlgConfig, target string) func() { continue } - // Determine import path: for relative dirs under module, use module path prefix; - // for absolute paths, derive from replace or use package name - dirName := filepath.Base(d) if !filepath.IsAbs(d) { importPath := modulePath + "/" + d stubs = append(stubs, importPath) } else { - // External directory: use the replace "from" key if found, else use dir name - found := false - for from, to := range plg.Replaces { + // External directory: must be in replaces to get a valid import path + for _, r := range plg.ReplacesToSlice() { + from, to, found := strings.Cut(r, "=") + if !found { + continue + } if absTo, _ := filepath.Abs(to); absTo == d { - stubs = append(stubs, from) - found = true + stubs = append(stubs, strings.TrimSpace(from)) break } } - if !found && dirName != "" { - stubs = append(stubs, modulePath+"/"+dirName) - } } } diff --git a/tools/plugindev/cmd_sdk.go b/tools/plugindev/cmd_sdk.go index 1678958..6635e67 100644 --- a/tools/plugindev/cmd_sdk.go +++ b/tools/plugindev/cmd_sdk.go @@ -11,6 +11,7 @@ import ( "path/filepath" "sort" "strings" + "time" ) const sdkDirName = "plugindev/sdk" @@ -165,7 +166,8 @@ func cmdSDKInstall(version string) { tmpPath := tmpFile.Name() defer os.Remove(tmpPath) - resp, err := http.Get(url) + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(url) if err != nil { fmt.Printf("error: download SDK %s: %v\n", version, err) os.Exit(1) @@ -191,12 +193,19 @@ func cmdSDKInstall(version string) { } defer os.RemoveAll(tmpDir) - gzr, err := gzip.NewReader(openFile(tmpPath)) + f, err := openFile(tmpPath) if err != nil { + fmt.Printf("error: open archive: %v\n", err) + os.Exit(1) + } + gzr, err := gzip.NewReader(f) + if err != nil { + f.Close() fmt.Printf("error: read archive: %v\n", err) os.Exit(1) } defer gzr.Close() + defer f.Close() tr := tar.NewReader(gzr) for { @@ -241,8 +250,12 @@ func cmdSDKInstall(version string) { gzr.Close() if err := os.Rename(tmpDir, dest); err != nil { - fmt.Printf("error: move SDK to store: %v\n", err) - os.Exit(1) + // Cross-filesystem rename fallback + if err := copyDir(tmpDir, dest); err != nil { + fmt.Printf("error: move SDK to store: %v\n", err) + os.Exit(1) + } + os.RemoveAll(tmpDir) } fmt.Printf("SDK version %s installed at %s\n", version, dest) @@ -253,12 +266,12 @@ func cmdSDKInstall(version string) { } } -func openFile(path string) *os.File { +func openFile(path string) (*os.File, error) { f, err := os.Open(path) if err != nil { - panic(err) + return nil, err } - return f + return f, nil } // cmdSDKUse switches the active SDK version. @@ -396,17 +409,19 @@ func compareSemver(a, b string) int { return 0 } -// parseSemver extracts [major, minor, patch] from a vX.Y.Z string. +// parseSemver extracts [major, minor, patch] from a vX.Y.Z[-pre] string. +// Prerelease tags parse to the same major.minor.patch as their release (ignoring prerelease). func parseSemver(tag string) [3]int { var v [3]int s := strings.TrimPrefix(tag, "v") + // Strip prerelease suffix (-...) + if idx := strings.IndexByte(s, '-'); idx >= 0 { + s = s[:idx] + } parts := strings.SplitN(s, ".", 3) - for i, p := range parts { - if i >= 3 { - break - } + for i := 0; i < 3 && i < len(parts); i++ { n := 0 - fmt.Sscanf(p, "%d", &n) + fmt.Sscanf(parts[i], "%d", &n) v[i] = n } return v @@ -433,6 +448,35 @@ func activeSDKRoot() string { return root } +// copyDir recursively copies src to dst (cross-filesystem rename fallback). +func copyDir(src, dst string) error { + if err := os.MkdirAll(dst, 0755); err != nil { + return err + } + entries, err := os.ReadDir(src) + if err != nil { + return err + } + for _, e := range entries { + srcPath := filepath.Join(src, e.Name()) + dstPath := filepath.Join(dst, e.Name()) + if e.IsDir() { + if err := copyDir(srcPath, dstPath); err != nil { + return err + } + } else { + data, err := os.ReadFile(srcPath) + if err != nil { + return err + } + if err := os.WriteFile(dstPath, data, 0644); err != nil { + return err + } + } + } + return nil +} + // readMetaVersion reads the Version string from the SDK's meta/meta.go. // If the file is missing or unreadable, returns "0.0.0". func readMetaVersion(sdkRoot string) string { diff --git a/tools/plugindev/go.mod b/tools/plugindev/go.mod index 4938de2..8f32b91 100644 --- a/tools/plugindev/go.mod +++ b/tools/plugindev/go.mod @@ -1,6 +1,6 @@ module github.com/JianFeeeee/homeagent-sdk/tools/plugindev -go 1.25.0 +go 1.21.0 require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0 diff --git a/tools/plugindev/replace.go b/tools/plugindev/replace.go index e446428..287aa29 100644 --- a/tools/plugindev/replace.go +++ b/tools/plugindev/replace.go @@ -30,7 +30,7 @@ func (p *GoModPatcher) Apply() (func(), error) { p.backup = string(data) var sb strings.Builder - sb.WriteString(strings.TrimRight(string(data), "\n")) + sb.WriteString(strings.TrimRight(string(data), "\r\n")) sb.WriteString("\n") for _, r := range p.replaces { from, to, found := strings.Cut(r, "=") diff --git a/tools/plugindev/templates.go b/tools/plugindev/templates.go index 2e9a7f8..9ce983a 100644 --- a/tools/plugindev/templates.go +++ b/tools/plugindev/templates.go @@ -390,6 +390,7 @@ var ( coreAPI unsafe.Pointer handlerMu sync.RWMutex + coreAPIMu sync.RWMutex toolHandlers = map[string]sdk.ToolHandler{} stageHandlers = map[string]sdk.StageHandler{} outputHandlers = map[string]sdk.ToolHandler{} @@ -398,29 +399,35 @@ var ( // ---- CoreAPI dispatch helpers ---- func callVoid(methodID int, s1, s2, s3 string, i1, i2 int) error { + coreAPIMu.RLock() + api := coreAPI + coreAPIMu.RUnlock() var c1, c2, c3 *C.char if s1 != "" { c1 = C.CString(s1); defer C.free(unsafe.Pointer(c1)) } if s2 != "" { c2 = C.CString(s2); defer C.free(unsafe.Pointer(c2)) } if s3 != "" { c3 = C.CString(s3); defer C.free(unsafe.Pointer(c3)) } var cErr *C.char - if C.ha_dispatch(C.int(methodID), coreAPI, c1, c2, c3, C.int(i1), C.int(i2), nil, &cErr) != 0 && cErr != nil { + if C.ha_dispatch(C.int(methodID), api, c1, c2, c3, C.int(i1), C.int(i2), nil, &cErr) != 0 && cErr != nil { return fmt.Errorf("%s", C.GoString(cErr)) } return nil } func callString(methodID int, s1, s2, s3 string, i1, i2 int) (string, error) { + coreAPIMu.RLock() + api := coreAPI + coreAPIMu.RUnlock() var c1, c2, c3 *C.char if s1 != "" { c1 = C.CString(s1); defer C.free(unsafe.Pointer(c1)) } if s2 != "" { c2 = C.CString(s2); defer C.free(unsafe.Pointer(c2)) } if s3 != "" { c3 = C.CString(s3); defer C.free(unsafe.Pointer(c3)) } var strResult, cErr *C.char - if C.ha_dispatch(C.int(methodID), coreAPI, c1, c2, c3, C.int(i1), C.int(i2), &strResult, &cErr) != 0 && cErr != nil { + if C.ha_dispatch(C.int(methodID), api, c1, c2, c3, C.int(i1), C.int(i2), &strResult, &cErr) != 0 && cErr != nil { return "", fmt.Errorf("%s", C.GoString(cErr)) } if strResult != nil { result := C.GoString(strResult) - C.ha_dispatch(C.int(25), coreAPI, strResult, nil, nil, 0, 0, nil, nil) + C.ha_dispatch(C.int(25), api, strResult, nil, nil, 0, 0, nil, nil) return result, nil } return "", nil @@ -571,7 +578,9 @@ func go_init_plugin(name *C.char, configJSON *C.char, errorOut **C.char) C.int { func go_start_plugin(coreAPIptr unsafe.Pointer, coreVersion C.int, errorOut **C.char) C.int { mu.Lock() plg := currentPlg + coreAPIMu.Lock() coreAPI = coreAPIptr + coreAPIMu.Unlock() mu.Unlock() _ = coreVersion if plg == nil { *errorOut = C.CString("not initialized"); return 1 } @@ -585,7 +594,9 @@ func go_stop_plugin(errorOut **C.char) C.int { mu.Lock() plg := currentPlg currentPlg = nil + coreAPIMu.Lock() coreAPI = nil + coreAPIMu.Unlock() mu.Unlock() if plg != nil { if err := plg.Stop(); err != nil { *errorOut = C.CString(err.Error()); return 1 } diff --git a/tools/plugindev/yaegi/mocksdk/plugin.go b/tools/plugindev/yaegi/mocksdk/plugin.go index 04be71c..1334604 100644 --- a/tools/plugindev/yaegi/mocksdk/plugin.go +++ b/tools/plugindev/yaegi/mocksdk/plugin.go @@ -345,23 +345,23 @@ func New(name string) *PluginSDK { func (s *PluginSDK) RegisterTool(name string, def ToolDef, handler ToolHandler) { logf("register_tool: %s", name) - mu.Lock() - defer mu.Unlock() + s.mu.Lock() + defer s.mu.Unlock() s.toolDefs[name] = def s.toolHandlers[name] = handler } func (s *PluginSDK) RegisterStage(stage Stage, handler StageHandler) { logf("register_stage: %s", string(stage)) - mu.Lock() - defer mu.Unlock() + s.mu.Lock() + defer s.mu.Unlock() s.stageHandlers[string(stage)] = handler } func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, handler ToolHandler) { logf("register_output_channel: %s", name) - mu.Lock() - defer mu.Unlock() + s.mu.Lock() + defer s.mu.Unlock() s.outChannels[name] = handler } @@ -370,9 +370,9 @@ func (s *PluginSDK) RegisterPluginAPI(name string) { } func (s *PluginSDK) CallTool(name string, args map[string]interface{}) (interface{}, error) { - mu.Lock() + s.mu.RLock() handler, ok := s.toolHandlers[name] - mu.Unlock() + s.mu.RUnlock() if !ok { return nil, fmt.Errorf("tool not found: %s", name) } @@ -380,9 +380,9 @@ func (s *PluginSDK) CallTool(name string, args map[string]interface{}) (interfac } func (s *PluginSDK) CallStage(stage string, ctx *StageContext) error { - mu.Lock() + s.mu.RLock() handler, ok := s.stageHandlers[stage] - mu.Unlock() + s.mu.RUnlock() if !ok { return nil } @@ -390,8 +390,8 @@ func (s *PluginSDK) CallStage(stage string, ctx *StageContext) error { } func (s *PluginSDK) ListTools() []ToolDef { - mu.Lock() - defer mu.Unlock() + s.mu.RLock() + defer s.mu.RUnlock() defs := make([]ToolDef, 0, len(s.toolDefs)) for _, def := range s.toolDefs { defs = append(defs, def)