diff --git a/.gitignore b/.gitignore index ea17a21..6414c8c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,9 +8,6 @@ plugin.json build/ dist/ -# Binaries -*.exe - # Test artifacts testdist/ @@ -21,5 +18,10 @@ testdist/ z_bridge_gen.go z_entry.c -# Binary -plugindev +# Binaries (except pre-built distributions in bin/) +/plugindev +*_debug* + +# Pre-built plugindev binaries in bin/ should be tracked +!bin/plugindev* +!bin/*.exe diff --git a/README.md b/README.md index 321ee06..1d335e1 100644 --- a/README.md +++ b/README.md @@ -136,16 +136,42 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg ```json { - "name": "my-plugin", + "name": "weather", + "name_zh": "天气查询", + "name_en": "Weather", "version": "1.0.0", - "lang": "go", - "entry": "main.go", - "description": "插件描述", - "channels": ["my-channel"], - "dependencies": {} + "description": "天气查询插件", + "author": "HomeAgent", + "entry": "plugin.so", + "tags": ["weather", "forecast"], + "targets": "linux/amd64,windows/amd64", + "outdir": "dist", + "bundle": true, + "replaces": { + "github.com/example/pkg": "../local/pkg" + }, + "source_dirs": [ + "../shared-lib" + ] } ``` +| 字段 | 类型 | 说明 | +|------|------|------| +| `name` | string | 插件标识名 | +| `name_zh` | string | 中文名 | +| `name_en` | string | 英文名 | +| `version` | string | 版本号 | +| `description` | string | 插件描述 | +| `author` | string | 作者 | +| `entry` | string | 入口文件(`plugin.so` / `main.lua`) | +| `tags` | string[] | 标签 | +| `targets` | string | 构建目标,逗号分隔(如 `linux/amd64,windows/amd64`) | +| `outdir` | string | 输出目录(默认 `dist`) | +| `bundle` | bool | 是否 bundle 模式(同时编译多平台) | +| `replaces` | object | Go 模块替换,key=模块路径,value=本地路径 | +| `source_dirs` | string[] | 额外源码搜索路径(编译时自动导入) | + ### .hmap 包格式 `.hmap` 为 ZIP 归档,包含: diff --git a/README_EN.md b/README_EN.md index b98c985..bf27904 100644 --- a/README_EN.md +++ b/README_EN.md @@ -136,16 +136,42 @@ Supports both **Go** and **Lua** plugin languages. ```json { - "name": "my-plugin", + "name": "weather", + "name_zh": "天气查询", + "name_en": "Weather", "version": "1.0.0", - "lang": "go", - "entry": "main.go", - "description": "Plugin description", - "channels": ["my-channel"], - "dependencies": {} + "description": "Weather plugin", + "author": "HomeAgent", + "entry": "plugin.so", + "tags": ["weather", "forecast"], + "targets": "linux/amd64,windows/amd64", + "outdir": "dist", + "bundle": true, + "replaces": { + "github.com/example/pkg": "../local/pkg" + }, + "source_dirs": [ + "../shared-lib" + ] } ``` +| Field | Type | Description | +|-------|------|-------------| +| `name` | string | Plugin identifier | +| `name_zh` | string | Chinese name | +| `name_en` | string | English name | +| `version` | string | Version | +| `description` | string | Plugin description | +| `author` | string | Author | +| `entry` | string | Entry file (`plugin.so` / `main.lua`) | +| `tags` | string[] | Tags | +| `targets` | string | Build targets, comma-separated (e.g. `linux/amd64,windows/amd64`) | +| `outdir` | string | Output directory (default `dist`) | +| `bundle` | bool | Bundle mode (build all platforms at once) | +| `replaces` | object | Go module replacements, key=module path, value=local path | +| `source_dirs` | string[] | Additional source search paths (auto-imported at build time) | + ### .hmap Package Format `.hmap` is a ZIP archive containing: diff --git a/bin/plugindev_darwin_amd64 b/bin/plugindev_darwin_amd64 new file mode 100644 index 0000000..b4f9d75 Binary files /dev/null and b/bin/plugindev_darwin_amd64 differ diff --git a/bin/plugindev_darwin_arm64 b/bin/plugindev_darwin_arm64 new file mode 100644 index 0000000..d85ca43 Binary files /dev/null and b/bin/plugindev_darwin_arm64 differ diff --git a/bin/plugindev_linux_amd64 b/bin/plugindev_linux_amd64 new file mode 100644 index 0000000..581e598 Binary files /dev/null and b/bin/plugindev_linux_amd64 differ diff --git a/bin/plugindev_linux_arm64 b/bin/plugindev_linux_arm64 new file mode 100644 index 0000000..86b2fdc Binary files /dev/null and b/bin/plugindev_linux_arm64 differ diff --git a/bin/plugindev_windows_amd64.exe b/bin/plugindev_windows_amd64.exe new file mode 100644 index 0000000..71fc9d6 Binary files /dev/null and b/bin/plugindev_windows_amd64.exe differ diff --git a/example/a2a/plg.json b/example/a2a/plg.json index ed94b6b..38beddb 100644 --- a/example/a2a/plg.json +++ b/example/a2a/plg.json @@ -1,4 +1,4 @@ -{ +{ "name": "a2a", "name_zh": "A2A 代理通信", "name_en": "A2A Agent Communication", @@ -7,5 +7,9 @@ "author": "HomeAgent", "entry": "plugin.so", "tags": ["a2a", "agent", "interop"], - "targets": "linux/amd64" + "targets": "linux/amd64", + "outdir": "dist", + "bundle": true, + "replaces": {}, + "source_dirs": [] } diff --git a/example/ai_image/plg.json b/example/ai_image/plg.json index 678af1e..0dbbdb7 100644 --- a/example/ai_image/plg.json +++ b/example/ai_image/plg.json @@ -1,4 +1,4 @@ -{ +{ "name": "ai_image", "name_zh": "AI绘图", "name_en": "AI Image", @@ -7,5 +7,9 @@ "author": "HomeAgent", "entry": "plugin.so", "tags": ["ai", "image", "draw", "generate"], - "targets": "linux/amd64" + "targets": "linux/amd64", + "outdir": "dist", + "bundle": true, + "replaces": {}, + "source_dirs": [] } diff --git a/example/bili/plg.json b/example/bili/plg.json index a537a61..1459568 100644 --- a/example/bili/plg.json +++ b/example/bili/plg.json @@ -1,4 +1,4 @@ -{ +{ "name": "bili", "name_zh": "B站视频下载", "name_en": "Bilibili Video Downloader", @@ -7,5 +7,9 @@ "author": "HomeAgent", "entry": "plugin.so", "tags": ["bili", "video", "download"], - "targets": "linux/amd64" + "targets": "linux/amd64", + "outdir": "dist", + "bundle": true, + "replaces": {}, + "source_dirs": [] } diff --git a/example/browser/plg.json b/example/browser/plg.json index 0cbb719..e5a37db 100644 --- a/example/browser/plg.json +++ b/example/browser/plg.json @@ -1,4 +1,4 @@ -{ +{ "name": "browser", "name_zh": "浏览器", "name_en": "Browser", @@ -7,5 +7,9 @@ "author": "HomeAgent", "entry": "plugin.so", "tags": ["web", "search", "fetch", "browser", "cdp"], - "targets": "linux/amd64" + "targets": "linux/amd64", + "outdir": "dist", + "bundle": true, + "replaces": {}, + "source_dirs": [] } diff --git a/example/calendar/plg.json b/example/calendar/plg.json index 72e39ed..f9096f9 100644 --- a/example/calendar/plg.json +++ b/example/calendar/plg.json @@ -1,4 +1,4 @@ -{ +{ "name": "calendar", "name_zh": "日历", "name_en": "Calendar", @@ -7,5 +7,9 @@ "author": "HomeAgent", "entry": "plugin.so", "tags": ["calendar", "event", "reminder", "schedule"], - "targets": "linux/amd64" -} \ No newline at end of file + "targets": "linux/amd64", + "outdir": "dist", + "bundle": true, + "replaces": {}, + "source_dirs": [] +} diff --git a/example/editdoc/plg.json b/example/editdoc/plg.json index 1465c83..116992e 100644 --- a/example/editdoc/plg.json +++ b/example/editdoc/plg.json @@ -1,4 +1,4 @@ -{ +{ "name": "editdoc", "name_zh": "文档编辑", "name_en": "Document Editor", @@ -7,5 +7,9 @@ "author": "HomeAgent", "entry": "plugin.so", "tags": ["editdoc", "office", "document"], - "targets": "linux/amd64" + "targets": "linux/amd64", + "outdir": "dist", + "bundle": true, + "replaces": {}, + "source_dirs": [] } diff --git a/example/files/plg.json b/example/files/plg.json index cad766d..9d76fc0 100644 --- a/example/files/plg.json +++ b/example/files/plg.json @@ -1,4 +1,4 @@ -{ +{ "name": "files", "name_zh": "文件系统", "name_en": "File System", @@ -7,5 +7,9 @@ "author": "HomeAgent", "entry": "plugin.so", "tags": ["files", "filesystem"], - "targets": "linux/amd64" + "targets": "linux/amd64", + "outdir": "dist", + "bundle": true, + "replaces": {}, + "source_dirs": [] } diff --git a/example/memo/plg.json b/example/memo/plg.json index dd5766e..99e8c74 100644 --- a/example/memo/plg.json +++ b/example/memo/plg.json @@ -1,4 +1,4 @@ -{ +{ "name": "memo", "name_zh": "备忘录", "name_en": "Memo/Notes", @@ -7,5 +7,9 @@ "author": "HomeAgent", "entry": "plugin.so", "tags": ["memo", "todo", "notes"], - "targets": "linux/amd64" + "targets": "linux/amd64", + "outdir": "dist", + "bundle": true, + "replaces": {}, + "source_dirs": [] } diff --git a/example/music/plg.json b/example/music/plg.json index 44ef173..07b2e2f 100644 --- a/example/music/plg.json +++ b/example/music/plg.json @@ -1,4 +1,4 @@ -{ +{ "name": "music", "name_zh": "音乐搜索", "name_en": "Music Search", @@ -7,5 +7,9 @@ "author": "HomeAgent", "entry": "plugin.so", "tags": ["music", "song", "lyrics", "网易云"], - "targets": "linux/amd64" -} \ No newline at end of file + "targets": "linux/amd64", + "outdir": "dist", + "bundle": true, + "replaces": {}, + "source_dirs": [] +} diff --git a/example/ocr/plg.json b/example/ocr/plg.json index c907591..f71105a 100644 --- a/example/ocr/plg.json +++ b/example/ocr/plg.json @@ -1,4 +1,4 @@ -{ +{ "name": "ocr", "name_zh": "OCR 文字识别", "name_en": "OCR Text Recognition", @@ -7,5 +7,9 @@ "author": "HomeAgent", "entry": "plugin.so", "tags": ["ocr", "image", "text"], - "targets": "linux/amd64" + "targets": "linux/amd64", + "outdir": "dist", + "bundle": true, + "replaces": {}, + "source_dirs": [] } diff --git a/example/qq/plg.json b/example/qq/plg.json index e62ba9e..2453c44 100644 --- a/example/qq/plg.json +++ b/example/qq/plg.json @@ -1,4 +1,4 @@ -{ +{ "name": "qq", "name_zh": "QQ消息", "name_en": "qq", @@ -7,5 +7,9 @@ "author": "HomeAgent", "entry": "plugin.so", "tags": ["qq", "messaging"], - "targets": "linux/amd64" + "targets": "linux/amd64", + "outdir": "dist", + "bundle": true, + "replaces": {}, + "source_dirs": [] } diff --git a/example/rss/plg.json b/example/rss/plg.json index cbe1ec8..3e2a1bf 100644 --- a/example/rss/plg.json +++ b/example/rss/plg.json @@ -1,4 +1,4 @@ -{ +{ "name": "rss", "name_zh": "RSS订阅", "name_en": "RSS", @@ -7,5 +7,9 @@ "author": "HomeAgent", "entry": "plugin.so", "tags": ["rss", "feed", "subscription", "monitor"], - "targets": "linux/amd64" -} \ No newline at end of file + "targets": "linux/amd64", + "outdir": "dist", + "bundle": true, + "replaces": {}, + "source_dirs": [] +} diff --git a/example/sanitizer/plg.json b/example/sanitizer/plg.json index 5a59cf4..6f75765 100644 --- a/example/sanitizer/plg.json +++ b/example/sanitizer/plg.json @@ -1,4 +1,4 @@ -{ +{ "name": "sanitizer", "name_zh": "输出清洗", "name_en": "sanitizer", @@ -7,5 +7,9 @@ "author": "HomeAgent SDK", "entry": "plugin.so", "tags": ["sanitizer"], - "targets": "linux/amd64" + "targets": "linux/amd64", + "outdir": "dist", + "bundle": true, + "replaces": {}, + "source_dirs": [] } diff --git a/example/weather/go.mod b/example/weather/go.mod index e1bd210..65fb497 100644 --- a/example/weather/go.mod +++ b/example/weather/go.mod @@ -5,3 +5,4 @@ go 1.25.0 require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0 replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk + diff --git a/example/weather/plg.json b/example/weather/plg.json index d91b5b8..eea5968 100644 --- a/example/weather/plg.json +++ b/example/weather/plg.json @@ -7,5 +7,10 @@ "author": "HomeAgent", "entry": "plugin.so", "tags": ["weather", "forecast", "wttr"], - "targets": "linux/amd64" -} \ No newline at end of file + "targets": "linux/amd64", + "outdir": "dist", + "bundle": true, + "replaces": {}, + "source_dirs": [] +} + diff --git a/meta/meta.go b/meta/meta.go index 5064097..9543e22 100644 --- a/meta/meta.go +++ b/meta/meta.go @@ -6,7 +6,7 @@ package meta var ( // Version 是 HomeAgent SDK 版本号。 // 通过 `-ldflags="-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=vX.Y.Z"` 注入。 - Version = "0.8.0" + Version = "0.7.2" // Commit 是构建时的 Git commit hash。 Commit = "unknown" diff --git a/tools/plugindev/cmd_build.go b/tools/plugindev/cmd_build.go index 66dc8c2..f99eb3b 100644 --- a/tools/plugindev/cmd_build.go +++ b/tools/plugindev/cmd_build.go @@ -13,55 +13,80 @@ import ( ) type BuildConfig struct { - OutDir string - Targets []string - Bundle bool - SDKPath string + OutDir string + Targets []string + Bundle bool + SDKPath string + Replaces []string } func cmdBuild(args []string) { - cfg := BuildConfig{OutDir: "dist"} - for i := 0; i < len(args); i++ { - switch args[i] { - case "--outdir": - if i+1 < len(args) { - cfg.OutDir = args[i+1]; i++ - } - case "--target": - if i+1 < len(args) { - cfg.Targets = append(cfg.Targets, args[i+1]); i++ - } - case "--bundle": - cfg.Bundle = true - case "--sdk-path": - if i+1 < len(args) { - cfg.SDKPath = args[i+1]; i++ - } - } - } - + // Read all config from plg.json first plg, err := readPlgJSON("plg.json") if err != nil { fmt.Printf("error: read plg.json: %v\n", err); os.Exit(1) } + // Base config from plg.json + outDir := plg.OutDirDefault() + targets := plg.TargetList() + bundle := plg.BundleDefault() + sdkPath := plg.SDKPath + var cliReplaces []string + + // CLI flags override plg.json + for i := 0; i < len(args); i++ { + switch args[i] { + case "--outdir": + if i+1 < len(args) { + outDir = args[i+1]; i++ + } + case "--target": + if i+1 < len(args) { + targets = append(targets, args[i+1]); i++ + } + case "--bundle": + bundle = true + case "--no-bundle": + bundle = false + case "--sdk-path": + if i+1 < len(args) { + sdkPath = args[i+1]; i++ + } + case "--replace", "-R": + if i+1 < len(args) { + cliReplaces = append(cliReplaces, args[i+1]); i++ + } + } + } + if plg.IsLua() { - buildTarget(plg, "lua", cfg.OutDir, "") + buildTarget(plg, "lua", outDir, "") return } // Ensure go.mod exists with correct SDK path - ensureGoMod(plg, cfg.SDKPath) + ensureGoMod(plg, sdkPath) - // Default: bundle mode (all 3 platforms in one .hmap) - if cfg.Bundle || len(cfg.Targets) == 0 { - buildBundle(plg, cfg.OutDir, cfg.SDKPath) + // Merge plg.json replaces + CLI overrides + replaceSlice := plg.ReplacesToSlice() + replaceSlice = append(replaceSlice, cliReplaces...) + + // Apply go.mod replace directives for single-target go build + gmPatcher := NewGoModPatcher(".", replaceSlice) + gmRestore, err := gmPatcher.Apply() + if err != nil { + fmt.Printf("warn: apply go.mod replaces: %v\n", err) + } + defer gmRestore() + + if bundle || len(targets) == 0 { + buildBundle(plg, outDir, sdkPath) return } - // Explicit --target: build each separately - for _, t := range cfg.Targets { - buildTarget(plg, t, cfg.OutDir, cfg.SDKPath) + for _, t := range targets { + buildTarget(plg, t, outDir, sdkPath) } } @@ -84,7 +109,7 @@ func buildBundle(plg *PlgConfig, outDir string, sdkPath string) { // Auto-generate C ABI bridge for non-Windows bridgeCleanup := generateBridge("") defer bridgeCleanup() - thirdpartCleanup := linkThirdpart("linux/amd64") + thirdpartCleanup := linkThirdpart(plg, "linux/amd64") defer thirdpartCleanup() var binaries []binEntry @@ -364,8 +389,8 @@ func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) { bridgeCleanup := generateBridge(cfg.goos) _ = bridgeCleanup // DISABLED cleanup for debug - // Auto-link thirdpart/ contents - thirdpartCleanup := linkThirdpart(target) + // Auto-link thirdpart/ contents + source_dirs + replace targets + thirdpartCleanup := linkThirdpart(plg, target) defer thirdpartCleanup() // Write plugin.json with the correct entry for this target @@ -605,26 +630,64 @@ func generateBridge(goos string) func() { } } -// linkThirdpart scans thirdpart/ for source files and generates auto-import stubs. -// For Go plugins: if thirdpart/*.go exists, generate z_thirdpart.go with import. -// For Lua plugins: no action needed (thirdpart/*.lua is packaged separately in buildTarget). -// Returns cleanup function to remove generated files. -func linkThirdpart(target string) func() { - const thirdpartDir = "thirdpart" +// linkThirdpart scans thirdpart/, source_dirs from plg.json, and replace target dirs +// for source files, generating auto-import stubs. Returns cleanup function. +func linkThirdpart(plg *PlgConfig, target string) func() { const importFile = "z_thirdpart.go" os.Remove(importFile) - if info, err := os.Stat(thirdpartDir); err != nil || !info.IsDir() { + if target == "lua" { return func() {} } - entries, err := os.ReadDir(thirdpartDir) + gomodPath := "go.mod" + data, err := os.ReadFile(gomodPath) if err != nil { return func() {} } + modulePath := "" + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "module ") { + modulePath = strings.TrimSpace(line[7:]) + break + } + } + if modulePath == "" { + return func() {} + } - // For Go builds: check for .go files - if target != "lua" { + // Collect directories to scan: thirdpart/ + source_dirs from plg.json + replace target dirs + var dirs []string + if info, err := os.Stat("thirdpart"); err == nil && info.IsDir() { + dirs = append(dirs, "thirdpart") + } + dirs = append(dirs, plg.SourceDirs...) + for _, to := range plg.Replaces { + if abs, err := filepath.Abs(to); err == nil { + if info, err := os.Stat(abs); err == nil && info.IsDir() { + dirs = append(dirs, abs) + } + } + } + + // Deduplicate + seen := map[string]bool{} + var unique []string + for _, d := range dirs { + abs, _ := filepath.Abs(d) + if abs != "" && !seen[abs] { + seen[abs] = true + unique = append(unique, d) + } + } + + // Generate import stubs for each directory with .go files + var stubs []string + for _, d := range unique { + entries, err := os.ReadDir(d) + if err != nil { + continue + } hasGo := false for _, e := range entries { if !e.IsDir() && strings.HasSuffix(e.Name(), ".go") { @@ -632,28 +695,41 @@ func linkThirdpart(target string) func() { break } } - if hasGo { - // Read go.mod to get the module path - gomodPath := "go.mod" - data, err := os.ReadFile(gomodPath) - if err != nil { - return func() {} - } - modulePath := "" - for _, line := range strings.Split(string(data), "\n") { - if strings.HasPrefix(line, "module ") { - modulePath = strings.TrimSpace(line[7:]) + if !hasGo { + 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 { + if absTo, _ := filepath.Abs(to); absTo == d { + stubs = append(stubs, from) + found = true break } } - if modulePath != "" { - importPath := modulePath + "/" + thirdpartDir - stub := "package main\nimport _ \"" + importPath + "\"\n" - os.WriteFile(importFile, []byte(stub), 0644) + if !found && dirName != "" { + stubs = append(stubs, modulePath+"/"+dirName) } } } + if len(stubs) > 0 { + var sb strings.Builder + sb.WriteString("package main\n") + for _, s := range stubs { + sb.WriteString("import _ \"" + s + "\"\n") + } + os.WriteFile(importFile, []byte(sb.String()), 0644) + } + return func() { os.Remove(importFile) } diff --git a/tools/plugindev/cmd_debug.go b/tools/plugindev/cmd_debug.go index 64cc89e..80cd9e4 100644 --- a/tools/plugindev/cmd_debug.go +++ b/tools/plugindev/cmd_debug.go @@ -6,6 +6,8 @@ import ( "os/exec" "path/filepath" "strings" + + "github.com/JianFeeeee/homeagent-sdk/tools/plugindev/yaegi" ) // tmplLuaDebug is the temporary Lua debug script template @@ -58,22 +60,52 @@ end repl() ` +type DebugConfig struct { + Dir string + Replaces []string +} + func cmdDebug(args []string) { - dir := "." - if len(args) > 0 && args[0] != "" { - dir = args[0] + cfg := DebugConfig{Dir: "."} + for i := 0; i < len(args); i++ { + switch args[i] { + case "--replace", "-R": + if i+1 < len(args) { + cfg.Replaces = append(cfg.Replaces, args[i+1]); i++ + } + default: + if !strings.HasPrefix(args[i], "-") { + cfg.Dir = args[i] + } + } } + dir := cfg.Dir luaPath := filepath.Join(dir, "main.lua") - goPath := filepath.Join(dir, "main.go") sdkPath := filepath.Join(dir, "sdk.lua") + hasGo := false + + entries, _ := os.ReadDir(dir) + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".go") && !strings.HasSuffix(e.Name(), "_test.go") { + hasGo = true + break + } + } + + // Merge plg.json replaces with CLI --replace overrides + var replaces []string + if plg, err := readPlgJSON(filepath.Join(dir, "plg.json")); err == nil { + replaces = plg.ReplacesToSlice() + } + replaces = append(replaces, cfg.Replaces...) if _, err := os.Stat(luaPath); err == nil { debugLua(dir, sdkPath, luaPath) - } else if _, err := os.Stat(goPath); err == nil { - debugGo(dir, goPath) + } else if hasGo { + debugGo(dir, replaces) } else { - fmt.Println("error: no main.lua or main.go found in", dir) + fmt.Println("error: no main.lua or .go files found in", dir) os.Exit(1) } } @@ -120,15 +152,43 @@ func debugLua(dir, sdkPath, luaPath string) { } } -func debugGo(dir, goPath string) { - fmt.Println("Go debug mode: use standard Go tooling") - fmt.Println() - fmt.Println(" go test -v ./... # run tests") - fmt.Println(" go build -o plugin.so -buildmode=plugin . # build plugin") - fmt.Println(" plugindev build # package as .hmap") - fmt.Println() - fmt.Println("For interactive Go debugging, use your IDE or dlv:") - fmt.Println(" dlv debug # Delve debugger") +func debugGo(dir string, replaces []string) { + debug, err := yaegi.NewGoPluginDebug(dir, replaces) + if err != nil { + fmt.Printf("error: %v\n", err) + os.Exit(1) + } + + fmt.Printf("[debug] Plugin dir: %s\n", dir) + + // Clean any stale debug harness + debug.Cleanup() + + // Try Yaegi interpreter first (fast, no compilation) + if err := debug.DebugWithYaegi(); err != nil { + // Fall back to go run with generated debug harness + // Apply third-party replace directives before go run + patcher := NewGoModPatcher(dir, replaces) + restore, pErr := patcher.Apply() + if pErr != nil { + fmt.Printf("[debug] warn: apply replaces: %v\n", pErr) + } + + if _, genErr := debug.GenerateDebugMain(); genErr != nil { + restore() + fmt.Printf("[debug] generate fallback: %v\n", genErr) + os.Exit(1) + } + + runErr := debug.DebugWithGoRun() + debug.Cleanup() + restore() + + if runErr != nil { + fmt.Printf("[debug] go run failed: %v\n", runErr) + os.Exit(1) + } + } } var _ = strings.TrimSpace diff --git a/tools/plugindev/cmd_init.go b/tools/plugindev/cmd_init.go index a5bd176..9c3c1ac 100644 --- a/tools/plugindev/cmd_init.go +++ b/tools/plugindev/cmd_init.go @@ -4,22 +4,52 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "text/template" "gitcode.com/JianFeeeee/homeagent-sdk/meta" ) +func (p *PlgConfig) ReplacesToSlice() []string { + var s []string + for from, to := range p.Replaces { + s = append(s, from+"="+to) + } + sort.Strings(s) // deterministic order + return s +} + type PlgConfig struct { - Name string `json:"name"` - NameZh string `json:"name_zh"` - NameEn string `json:"name_en"` - Version string `json:"version"` - Description string `json:"description"` - Author string `json:"author"` - Entry string `json:"entry"` - Tags []string `json:"tags"` - Targets string `json:"targets"` + Name string `json:"name"` + NameZh string `json:"name_zh"` + NameEn string `json:"name_en"` + Version string `json:"version"` + Description string `json:"description"` + Author string `json:"author"` + Entry string `json:"entry"` + Tags []string `json:"tags"` + Targets string `json:"targets"` + OutDir string `json:"outdir,omitempty"` + Bundle *bool `json:"bundle,omitempty"` + SDKPath string `json:"sdk_path,omitempty"` + GoVersion string `json:"go_version,omitempty"` + Replaces map[string]string `json:"replaces,omitempty"` + SourceDirs []string `json:"source_dirs,omitempty"` +} + +// TargetList parses the Targets string into a slice. +func (p *PlgConfig) TargetList() []string { return parseTargets(p.Targets) } + +// BundleDefault returns true if bundle mode is not explicitly disabled. +func (p *PlgConfig) BundleDefault() bool { return p.Bundle == nil || *p.Bundle } + +// OutDirDefault returns the output directory, defaulting to "dist". +func (p *PlgConfig) OutDirDefault() string { + if p.OutDir != "" { + return p.OutDir + } + return "dist" } type TemplateData struct { diff --git a/tools/plugindev/go.mod b/tools/plugindev/go.mod index 0055fa8..4938de2 100644 --- a/tools/plugindev/go.mod +++ b/tools/plugindev/go.mod @@ -4,4 +4,6 @@ go 1.25.0 require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0 +require github.com/traefik/yaegi v0.16.1 + replace gitcode.com/JianFeeeee/homeagent-sdk => ../../ diff --git a/tools/plugindev/go.sum b/tools/plugindev/go.sum new file mode 100644 index 0000000..435aa13 --- /dev/null +++ b/tools/plugindev/go.sum @@ -0,0 +1,2 @@ +github.com/traefik/yaegi v0.16.1 h1:f1De3DVJqIDKmnasUF6MwmWv1dSEEat0wcpXhD2On3E= +github.com/traefik/yaegi v0.16.1/go.mod h1:4eVhbPb3LnD2VigQjhYbEJ69vDRFdT2HQNrXx8eEwUY= diff --git a/tools/plugindev/replace.go b/tools/plugindev/replace.go new file mode 100644 index 0000000..e446428 --- /dev/null +++ b/tools/plugindev/replace.go @@ -0,0 +1,77 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +type GoModPatcher struct { + dir string + replaces []string + backup string +} + +func NewGoModPatcher(dir string, replaces []string) *GoModPatcher { + return &GoModPatcher{dir: dir, replaces: replaces} +} + +func (p *GoModPatcher) Apply() (func(), error) { + if len(p.replaces) == 0 { + return func() {}, nil + } + + gomodPath := filepath.Join(p.dir, "go.mod") + data, err := os.ReadFile(gomodPath) + if err != nil { + return func() {}, fmt.Errorf("read go.mod: %w", err) + } + p.backup = string(data) + + var sb strings.Builder + sb.WriteString(strings.TrimRight(string(data), "\n")) + sb.WriteString("\n") + for _, r := range p.replaces { + from, to, found := strings.Cut(r, "=") + if !found { + fmt.Printf(" warn: invalid replace %q, skipping\n", r) + continue + } + from = strings.TrimSpace(from) + to = strings.TrimSpace(to) + absTo, err := filepath.Abs(to) + if err != nil { + fmt.Printf(" warn: resolve path %q: %v, skipping\n", to, err) + continue + } + absTo = strings.ReplaceAll(absTo, "\\", "/") + sb.WriteString(fmt.Sprintf("replace %s => %s\n", from, absTo)) + } + + if err := os.WriteFile(gomodPath, []byte(sb.String()), 0644); err != nil { + return func() {}, fmt.Errorf("write go.mod: %w", err) + } + + return p.restore, nil +} + +func (p *GoModPatcher) restore() { + if p.backup == "" { + return + } + gomodPath := filepath.Join(p.dir, "go.mod") + os.WriteFile(gomodPath, []byte(p.backup), 0644) + p.backup = "" +} + +func (p *GoModPatcher) ReplaceDirs() []string { + var dirs []string + for _, r := range p.replaces { + _, to, found := strings.Cut(r, "=") + if found { + dirs = append(dirs, strings.TrimSpace(to)) + } + } + return dirs +} diff --git a/tools/plugindev/yaegi/interp.go b/tools/plugindev/yaegi/interp.go new file mode 100644 index 0000000..5d31001 --- /dev/null +++ b/tools/plugindev/yaegi/interp.go @@ -0,0 +1,279 @@ +package yaegi + +import ( + "bufio" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "reflect" + "strings" + + "github.com/traefik/yaegi/interp" + "github.com/traefik/yaegi/stdlib" + "github.com/JianFeeeee/homeagent-sdk/tools/plugindev/yaegi/mocksdk" +) + +type YaegiDebugger struct { + Dir string + PluginName string + interp *interp.Interpreter +} + +func findSDKGoPath(pluginDir string) string { + // Try to find SDK root from plugin's go.mod replace directive + gm := filepath.Join(pluginDir, "go.mod") + if data, err := os.ReadFile(gm); err == nil { + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "replace ") && strings.Contains(line, "homeagent-sdk") { + parts := strings.Fields(line) + for _, p := range parts { + if strings.Contains(p, "homeagent-sdk") && strings.Contains(p, string(filepath.Separator)) { + return filepath.Dir(filepath.Dir(p)) + } + } + } + } + } + // Fallback: check common relative locations + candidates := []string{ + filepath.Join(pluginDir, "..", ".."), + filepath.Join(pluginDir, "..", "..", ".."), + } + for _, c := range candidates { + abs, _ := filepath.Abs(c) + if _, err := os.Stat(filepath.Join(abs, "homeagentsdk", "go.mod")); err == nil { + return abs + } + } + return "" +} + +func findModuleDir(dir string) string { + abs, _ := filepath.Abs(dir) + for { + if _, err := os.Stat(filepath.Join(abs, "go.mod")); err == nil { + return abs + } + parent := filepath.Dir(abs) + if parent == abs { + return "" + } + abs = parent + } +} + +func NewYaegiDebugger(dir string, replaces []string) (*YaegiDebugger, error) { + absDir, err := filepath.Abs(dir) + if err != nil { + return nil, fmt.Errorf("resolve dir: %w", err) + } + + goPath := findSDKGoPath(dir) + // Add replace target directories to GoPath for Yaegi resolution + for _, r := range replaces { + _, to, found := strings.Cut(r, "=") + if !found { + continue + } + to = strings.TrimSpace(to) + absTo, err := filepath.Abs(to) + if err != nil { + continue + } + absTo = strings.ReplaceAll(absTo, "\\", "/") + // Walk up to find module root (contains go.mod) + modDir := findModuleDir(absTo) + if modDir != "" { + parent := filepath.Dir(modDir) + if goPath == "" { + goPath = parent + } else if !strings.Contains(goPath, parent) { + goPath += string(os.PathListSeparator) + parent + } + } + } + + i := interp.New(interp.Options{ + GoPath: goPath, + }) + i.Use(stdlib.Symbols) + + sdkExports := make(interp.Exports) + pkg := make(map[string]reflect.Value) + pkg["New"] = reflect.ValueOf(mocksdk.New) + pkg["NewPluginSDK"] = reflect.ValueOf(mocksdk.NewPluginSDK) + pkg["StageOnInput"] = reflect.ValueOf(mocksdk.StageOnInput) + pkg["StagePreAction"] = reflect.ValueOf(mocksdk.StagePreAction) + pkg["StagePostAction"] = reflect.ValueOf(mocksdk.StagePostAction) + pkg["StageBeforeToolcall"] = reflect.ValueOf(mocksdk.StageBeforeToolcall) + pkg["StageAfterToolcall"] = reflect.ValueOf(mocksdk.StageAfterToolcall) + pkg["StageBeforeOutput"] = reflect.ValueOf(mocksdk.StageBeforeOutput) + pkg["StageAfterOutput"] = reflect.ValueOf(mocksdk.StageAfterOutput) + pkg["StageScopeGlobal"] = reflect.ValueOf(mocksdk.StageScopeGlobal) + pkg["StageScopeOwnTools"] = reflect.ValueOf(mocksdk.StageScopeOwnTools) + + typeRegistry := []interface{}{ + (*mocksdk.PluginSDK)(nil), + (*mocksdk.Plugin)(nil), + mocksdk.ToolDef{}, + mocksdk.ToolHandler(nil), + mocksdk.StageHandler(nil), + (*mocksdk.StageContext)(nil), + mocksdk.Stage(""), + mocksdk.ConfigDef{}, + mocksdk.Entity{}, + mocksdk.Relation{}, + mocksdk.Triple{}, + (*mocksdk.Doc)(nil), + mocksdk.TextEvent{}, + (*mocksdk.Knowledge)(nil), + (*mocksdk.PersonProfile)(nil), + mocksdk.SocialRelation{}, + mocksdk.MemItem{}, + mocksdk.ToolCall{}, + mocksdk.ToolResult{}, + (*mocksdk.SettingsAPI)(nil), + (*mocksdk.MemoryAPI)(nil), + (*mocksdk.DocMemoryAPI)(nil), + (*mocksdk.TextMemoryAPI)(nil), + (*mocksdk.KnowledgeAPI)(nil), + (*mocksdk.SocialAPI)(nil), + (*mocksdk.LLMAPI)(nil), + (*mocksdk.IOInjector)(nil), + } + + for _, t := range typeRegistry { + rt := reflect.TypeOf(t) + name := rt.Name() + if name == "" { + name = rt.Elem().Name() + } + pkg[name] = reflect.ValueOf(t) + } + + sdkExports["gitcode.com/JianFeeeee/homeagent-sdk/sdk"] = pkg + i.Use(sdkExports) + + return &YaegiDebugger{ + Dir: absDir, + PluginName: filepath.Base(absDir), + interp: i, + }, nil +} + +func (d *YaegiDebugger) LoadPlugin() error { + entries, err := os.ReadDir(d.Dir) + if err != nil { + return fmt.Errorf("read dir: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") { + continue + } + if strings.HasSuffix(entry.Name(), "_test.go") { + continue + } + if entry.Name() == "debug_main.go" { + continue + } + + src, err := os.ReadFile(filepath.Join(d.Dir, entry.Name())) + if err != nil { + return fmt.Errorf("read %s: %w", entry.Name(), err) + } + + _, err = d.interp.Eval(string(src)) + if err != nil { + return fmt.Errorf("eval %s: %w", entry.Name(), err) + } + } + + return nil +} + +func (d *YaegiDebugger) HasNewPluginFactory() (bool, error) { + fset := token.NewFileSet() + pkgs, err := parser.ParseDir(fset, d.Dir, nil, 0) + if err != nil { + return false, err + } + for _, pkg := range pkgs { + for _, f := range pkg.Files { + for _, decl := range f.Decls { + if fn, ok := decl.(*ast.FuncDecl); ok && fn.Name.Name == "NewPluginFactory" { + return true, nil + } + } + } + } + return false, nil +} + +func (d *YaegiDebugger) StartREPL() error { + hasFactory, _ := d.HasNewPluginFactory() + + if hasFactory { + v, err := d.interp.Eval(fmt.Sprintf(`NewPluginFactory("%s", nil)`, d.PluginName)) + if err != nil { + return fmt.Errorf("call NewPluginFactory: %w", err) + } + + plugin := v.Interface().(mocksdk.Plugin) + fmt.Printf("[debug] Plugin: %s\n", plugin.Name()) + + sdk := mocksdk.New(d.PluginName) + if err := plugin.Start(sdk); err != nil { + return fmt.Errorf("plugin.Start: %w", err) + } + fmt.Printf("[debug] Plugin started. Registered %d tools.\n", len(sdk.ListTools())) + for _, def := range sdk.ListTools() { + fmt.Printf(" - %s: %s\n", def.Name, def.Description) + } + } + + fmt.Println() + fmt.Println("=== Yaegi REPL ===") + fmt.Println("Type Go expressions, 'tools' to list, 'call ' to invoke, 'exit' to quit.") + fmt.Println() + + scanner := bufio.NewScanner(os.Stdin) + for { + fmt.Print("> ") + if !scanner.Scan() { + break + } + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + if line == "exit" || line == "quit" || line == "q" { + break + } + if line == "tools" { + if hasFactory { + v, _ := d.interp.Eval(fmt.Sprintf(`NewPluginFactory("%s", nil)`, d.PluginName)) + plugin := v.Interface().(mocksdk.Plugin) + sdk := mocksdk.New(d.PluginName) + plugin.Start(sdk) + for _, def := range sdk.ListTools() { + fmt.Printf(" %s: %s\n", def.Name, def.Description) + } + } + continue + } + + v, err := d.interp.Eval(line) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + } else if v.IsValid() && v.CanInterface() { + result := v.Interface() + fmt.Printf("%+v\n", result) + } + } + return nil +} diff --git a/tools/plugindev/yaegi/mocksdk/plugin.go b/tools/plugindev/yaegi/mocksdk/plugin.go new file mode 100644 index 0000000..04be71c --- /dev/null +++ b/tools/plugindev/yaegi/mocksdk/plugin.go @@ -0,0 +1,410 @@ +package mocksdk + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "sync" +) + +var ( + DebugLog = false + mu sync.Mutex +) + +func logf(format string, args ...interface{}) { + if DebugLog { + fmt.Fprintf(os.Stderr, "[mocksdk] "+format+"\n", args...) + } +} + +type Plugin interface { + Name() string + Start(sdk *PluginSDK) error + Stop() error +} + +type ToolHandler func(args map[string]interface{}) (interface{}, error) + +type StageHandler func(ctx *StageContext) error + +type Stage string + +const ( + StageOnInput Stage = "on_input" + StagePreAction Stage = "pre_action" + StagePostAction Stage = "post_action" + StageBeforeToolcall Stage = "before_toolcall" + StageAfterToolcall Stage = "after_toolcall" + StageBeforeOutput Stage = "before_output" + StageAfterOutput Stage = "after_output" +) + +type StageContext struct { + mu sync.RWMutex + RawMessage string + UserID string + GroupID string + ContextMsgs []map[string]interface{} + LLMText string + ReasoningContent string + TokenUsage map[string]int + ToolCalls []ToolCall + ToolResults []ToolResult + FinalText string + Response *string + Phase Stage + Memory []MemItem + NoMemory bool + Extra map[string]interface{} + Errors []string +} + +type MemItem struct { + Role string `json:"role"` + Content string `json:"content"` + Score float64 `json:"score"` +} + +type ToolCall struct { + ID string `json:"id"` + Name string `json:"name"` + Plugin string `json:"plugin,omitempty"` + Arguments map[string]interface{} `json:"arguments"` +} + +type ToolResult struct { + CallID string `json:"call_id"` + Name string `json:"name"` + Plugin string `json:"plugin,omitempty"` + Success bool `json:"success"` + Result interface{} `json:"result"` +} + +type ToolDef struct { + Name string `json:"name"` + Plugin string `json:"plugin,omitempty"` + Description string `json:"description"` + Parameters map[string]interface{} `json:"parameters"` + NoMemory bool `json:"no_memory,omitempty"` + Cleaner func(string) string `json:"-"` +} + +type IOInjector interface { + InjectInterruptText(source, channel, text string) + InjectText(source, channel, text string) + InjectTextNoMemory(source, channel, text string) +} + +type EventType string + +const ( + EventRawInput EventType = "raw_input" + EventAgentOutput EventType = "agent_output" + EventAgentLLMChain EventType = "agent_llm_chain" + EventToolCall EventType = "tool_call" + EventReasoning EventType = "reasoning" + EventStage EventType = "stage" + EventSystem EventType = "system" +) + +type Event struct { + Type EventType `json:"type"` + Source string `json:"source"` + Payload map[string]interface{} `json:"payload"` + Timestamp int64 `json:"timestamp"` +} + +type EventHandler func(evt *Event) + +type EventSubscriber interface { + Subscribe(eventType EventType, handler EventHandler) func() +} + +type StageScope int + +const ( + StageScopeGlobal StageScope = 0 + StageScopeOwnTools StageScope = 1 +) + +type ConfigDef struct { + Key string `json:"key"` + Default string `json:"default"` + Type string `json:"type"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + Category string `json:"category"` + Options []string `json:"options,omitempty"` +} + +type SettingsAPI interface { + Get(key string) (interface{}, error) + Set(key string, value interface{}) error + List(prefix string) ([]string, error) + GetCore(key string) (interface{}, error) + SetCore(key string, value interface{}) error + ListCore(prefix string) ([]string, error) + GetPlugin(plugin, key string) (interface{}, error) + SetPlugin(plugin, key string, value interface{}) error + ListPlugin(plugin, prefix string) ([]string, error) + RegisterDef(def ConfigDef) + Defs(prefix string) []*ConfigDef + Dump() map[string]interface{} + Plugins() []string +} + +type mockSettings struct{ data map[string]interface{} } + +func (s *mockSettings) Get(key string) (interface{}, error) { + v, ok := s.data[key] + if !ok { return nil, nil } + return v, nil +} +func (s *mockSettings) Set(key string, value interface{}) error { s.data[key] = value; return nil } +func (s *mockSettings) List(prefix string) ([]string, error) { + var ks []string + for k := range s.data { + if strings.HasPrefix(k, prefix) { ks = append(ks, k) } + } + return ks, nil +} +func (s *mockSettings) GetCore(key string) (interface{}, error) { return nil, nil } +func (s *mockSettings) SetCore(key string, value interface{}) error { return nil } +func (s *mockSettings) ListCore(prefix string) ([]string, error) { return nil, nil } +func (s *mockSettings) GetPlugin(p, k string) (interface{}, error) { return nil, nil } +func (s *mockSettings) SetPlugin(p, k string, v interface{}) error { return nil } +func (s *mockSettings) ListPlugin(p, prefix string) ([]string, error) { return nil, nil } +func (s *mockSettings) RegisterDef(def ConfigDef) { + logf("config def: %s = %s", def.Key, def.Default) +} +func (s *mockSettings) Defs(prefix string) []*ConfigDef { return nil } +func (s *mockSettings) Dump() map[string]interface{} { return s.data } +func (s *mockSettings) Plugins() []string { return nil } + +type Entity struct { + Name string `json:"name"` + Type string `json:"type"` + Properties map[string]string `json:"properties,omitempty"` +} + +type Relation struct { + Subject string `json:"subject"` + Predicate string `json:"predicate"` + Object string `json:"object"` +} + +type Triple struct { + Subject string `json:"subject"` + Predicate string `json:"predicate"` + Object string `json:"object"` +} + +type MemoryAPI interface { + Recall(q []string, depth int) ([]Entity, []Relation, error) + Commit(triples []Triple) error + Introspect() (map[string]interface{}, error) + MergeEntities(source, target string) (int, error) + Purge(conditions map[string]string, mode string) (int, error) +} + +type mockMemory struct{} + +func (mockMemory) Recall(q []string, d int) ([]Entity, []Relation, error) { return nil, nil, nil } +func (mockMemory) Commit(t []Triple) error { return nil } +func (mockMemory) Introspect() (map[string]interface{}, error) { return map[string]interface{}{}, nil } +func (mockMemory) MergeEntities(s, t string) (int, error) { return 0, nil } +func (mockMemory) Purge(c map[string]string, m string) (int, error) { return 0, nil } + +type Doc struct { + ID string `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + Source string `json:"source"` +} + +type DocMemoryAPI interface { + Query(text string, topK int) []*Doc + Insert(doc *Doc) error + Remove(id string) + Stats() map[string]interface{} +} + +type mockDocMemory struct{} + +func (mockDocMemory) Query(t string, k int) []*Doc { return nil } +func (mockDocMemory) Insert(doc *Doc) error { return nil } +func (mockDocMemory) Remove(id string) {} +func (mockDocMemory) Stats() map[string]interface{} { return nil } + +type TextEvent struct { + Timestamp int64 `json:"timestamp"` + Role string `json:"role"` + Content string `json:"content"` + Source string `json:"source"` +} + +type TextMemoryAPI interface { + Append(evt TextEvent) error +} + +type mockTextMemory struct{} + +func (mockTextMemory) Append(evt TextEvent) error { return nil } + +type Knowledge struct { + Name string `json:"name"` + Content string `json:"content"` +} + +type KnowledgeAPI interface { + Search(query string, topK int) ([]*Knowledge, error) + Add(name, content string) error + List() ([]string, error) +} + +type mockKnowledge struct{} + +func (mockKnowledge) Search(q string, k int) ([]*Knowledge, error) { return nil, nil } +func (mockKnowledge) Add(n, c string) error { return nil } +func (mockKnowledge) List() ([]string, error) { return nil, nil } + +type PersonProfile struct { + Name string `json:"name"` + Traits map[string]string `json:"traits"` +} + +type SocialRelation struct { + Target string `json:"target"` + Relation string `json:"relation"` +} + +type SocialAPI interface { + GetPerson(name string) (*PersonProfile, error) + GetTrait(name, trait string) (string, bool) + GetRelations(name string) ([]SocialRelation, error) + GetNetwork(name string, depth int) ([]*PersonProfile, error) + ListPersons() ([]string, error) +} + +type mockSocial struct{} + +func (mockSocial) GetPerson(n string) (*PersonProfile, error) { return nil, nil } +func (mockSocial) GetTrait(n, t string) (string, bool) { return "", false } +func (mockSocial) GetRelations(name string) ([]SocialRelation, error) { return nil, nil } +func (mockSocial) GetNetwork(n string, d int) ([]*PersonProfile, error) { return nil, nil } +func (mockSocial) ListPersons() ([]string, error) { return nil, nil } + +type LLMAPI interface { + ListSources() []string + SetSource(name string) error + CurrentSource() string +} + +type mockLLM struct{} + +func (mockLLM) ListSources() []string { return nil } +func (mockLLM) SetSource(n string) error { return nil } +func (mockLLM) CurrentSource() string { return "" } + +type IOInjectorImpl struct{} + +func (IOInjectorImpl) InjectInterruptText(source, channel, text string) { + logf("inject_interrupt: source=%s channel=%s", source, channel) +} +func (IOInjectorImpl) InjectText(source, channel, text string) { + logf("inject_text: source=%s channel=%s", source, channel) +} +func (IOInjectorImpl) InjectTextNoMemory(source, channel, text string) { + logf("inject_text_no_memory: source=%s channel=%s", source, channel) +} + +type PluginSDK struct { + Name string + mu sync.RWMutex + toolDefs map[string]ToolDef + toolHandlers map[string]ToolHandler + stageHandlers map[string]StageHandler + outChannels map[string]ToolHandler + Settings SettingsAPI + IO IOInjector +} + +func New(name string) *PluginSDK { + return &PluginSDK{ + Name: name, + toolDefs: make(map[string]ToolDef), + toolHandlers: make(map[string]ToolHandler), + stageHandlers: make(map[string]StageHandler), + outChannels: make(map[string]ToolHandler), + Settings: &mockSettings{data: map[string]interface{}{}}, + IO: IOInjectorImpl{}, + } +} + +func (s *PluginSDK) RegisterTool(name string, def ToolDef, handler ToolHandler) { + logf("register_tool: %s", name) + mu.Lock() + defer 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.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.outChannels[name] = handler +} + +func (s *PluginSDK) RegisterPluginAPI(name string) { + logf("register_api: %s", name) +} + +func (s *PluginSDK) CallTool(name string, args map[string]interface{}) (interface{}, error) { + mu.Lock() + handler, ok := s.toolHandlers[name] + mu.Unlock() + if !ok { + return nil, fmt.Errorf("tool not found: %s", name) + } + return handler(args) +} + +func (s *PluginSDK) CallStage(stage string, ctx *StageContext) error { + mu.Lock() + handler, ok := s.stageHandlers[stage] + mu.Unlock() + if !ok { + return nil + } + return handler(ctx) +} + +func (s *PluginSDK) ListTools() []ToolDef { + mu.Lock() + defer mu.Unlock() + defs := make([]ToolDef, 0, len(s.toolDefs)) + for _, def := range s.toolDefs { + defs = append(defs, def) + } + return defs +} + +func (s *PluginSDK) ListToolsJSON() string { + defs := s.ListTools() + b, _ := json.MarshalIndent(defs, "", " ") + return string(b) +} + +func NewPluginSDK(name string) *PluginSDK { + return New(name) +}