From 21221f20c5ad7b28abe17635eec97b2736bc9aea Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 13 Sep 2026 14:16:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(example):=20plugindev=20=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=20=E2=80=94=E2=80=94=20=E6=8A=8A=20hmapdev=20=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E9=93=BE=E5=B0=81=E8=A3=85=E6=88=90=20Agent=20?= =?UTF-8?q?=E5=8F=AF=E8=B0=83=E7=94=A8=E7=9A=84=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户要求:把 SDK 的 hmapdev 额外封装为 HomeAgent 插件并装上。 ## 为什么 hmapdev 原本是"给人/CI 用"的命令行。做成插件后,Agent 能自己完成 「新建插件 → 构建 → 安装 → 重载」全流程(配合已有的 plugin_install / plgreload): plugindev_init → plugindev_build → plugin_install(path) → plgreload ## 工具面(5 个) `plugindev_status`(可用性/版本/当前 SDK/工作区,排障首选)、 `plugindev_init`(脚手架,插件名约束 `[a-zA-Z0-9_-]{1,64}`)、 `plugindev_build`(在工程目录构建打包,返回产物路径与下一步提示)、 `plugindev_sdk`(SDK 版本 list/current/path/latest/install/use,`from` 支持本地源码)、 `plugindev_projects`(列出工作区已有工程与产物)。 ## 安全边界(实现里落实) - 只 exec **hmapdev 一个可执行文件**,不做 shell 拼接; - `plugindev_build` 只接受含 `plg.json` 的目录 —— 这个工具不会变成"对任意目录跑构建"; - 子进程全部带超时;输出截断(6000 字符,保留首尾)后才返回,避免几百 KB 构建日志灌爆模型上下文; - 工作区默认落在 `/plugindev`,配置可改。 ## 现场验证 已在本机生产装上(`plugin_install` 带 path):5 个工具注册成功、 启动日志 `[plugindev] 就绪:hmapdev=/usr/local/bin/hmapdev 工作区=/home/newqqagent/plugindev`, `hmapdev` 已装到 `/usr/local/bin/hmapdev`(版本 1.3.0)。 --- example/plugindev/README.md | 47 ++++ example/plugindev/go.mod | 7 + example/plugindev/main.go | 11 + example/plugindev/plg.json | 19 ++ example/plugindev/plugin.go | 455 ++++++++++++++++++++++++++++++++++++ 5 files changed, 539 insertions(+) create mode 100644 example/plugindev/README.md create mode 100644 example/plugindev/go.mod create mode 100644 example/plugindev/main.go create mode 100644 example/plugindev/plg.json create mode 100644 example/plugindev/plugin.go diff --git a/example/plugindev/README.md b/example/plugindev/README.md new file mode 100644 index 0000000..ed0a69b --- /dev/null +++ b/example/plugindev/README.md @@ -0,0 +1,47 @@ +# plugindev — 插件开发工具链(Agent 可调用) + +把 SDK 的 `hmapdev` 封装成插件,让 **Agent 自己**走完「新建插件 → 构建 → 安装」全流程, +不需要人来敲命令行: + +``` +plugindev_init 生成工程骨架(等价 hmapdev init [--lua]) + ↓ 改 plugin.go +plugindev_build 构建打包(等价在该目录 hmapdev build)→ dist/*.hmap + ↓ +plugin_install 安装(用 path 指向刚构建出的 .hmap,overwrite=true 表示原地更新) + ↓ +plgreload 重载生效 +``` + +## 工具 + +| 工具 | 参数 | 说明 | +|---|---|---| +| `plugindev_status` | — | hmapdev 是否可用/版本/当前 SDK 版本与路径/工作区;**排查"为什么不能构建"先用它** | +| `plugindev_init` | `name`、`lang`(go/lua)、`dir` | 生成工程骨架;插件名必须 `[a-zA-Z0-9_-]{1,64}` | +| `plugindev_build` | `dir`、`target` | 在工程目录构建打包;产物路径会在返回里给出 | +| `plugindev_sdk` | `action`、`version`、`from` | SDK 版本管理(list/current/path/latest/install/use);`from` 可指向本地 SDK 源码 | +| `plugindev_projects` | — | 列出工作区里已有工程与产物 | + +## 配置 + +| 键 | 默认 | 说明 | +|---|---|---| +| `hmapdev_path` | 自动查找 | 依次尝试:本配置项 → PATH → `/usr/local/bin/hmapdev` → `/root/go/bin/hmapdev` | +| `workspace_dir` | `/plugindev` | `plugindev_init` 生成工程的默认目录 | +| `build_timeout_sec` | 600 | 单次 hmapdev 调用超时 | + +## 前置:装 hmapdev + +```bash +cd /tools/hmapdev && go build -buildvcs=false -o /usr/local/bin/hmapdev . +hmapdev version +``` + +## 安全边界(都在实现里,不只写在文档里) + +- 只 exec **hmapdev 一个可执行文件**,不接受任意命令、不做 shell 拼接; +- `plugindev_build` 只接受含 `plg.json` 的目录("看起来是插件工程"才构建), + 避免把这个工具变成对任意目录跑构建; +- 子进程全部带超时,输出**截断**后才返回(构建日志动辄几百 KB,直接回灌会撑爆模型上下文); +- 工程名约束与内核/上游对"进工具名的标识符"的规则一致(`[a-zA-Z0-9_-]{1,64}`)。 diff --git a/example/plugindev/go.mod b/example/plugindev/go.mod new file mode 100644 index 0000000..b16ee3b --- /dev/null +++ b/example/plugindev/go.mod @@ -0,0 +1,7 @@ +module plugindev + +go 1.25.0 + +require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0 + +replace gitcode.com/JianFeeeee/homeagent-sdk => ../../ diff --git a/example/plugindev/main.go b/example/plugindev/main.go new file mode 100644 index 0000000..67c3a3c --- /dev/null +++ b/example/plugindev/main.go @@ -0,0 +1,11 @@ +//go:build !windows || !cgo + +package main + +import ( + sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { + return NewPluginFactory(name, config) +} diff --git a/example/plugindev/plg.json b/example/plugindev/plg.json new file mode 100644 index 0000000..7537309 --- /dev/null +++ b/example/plugindev/plg.json @@ -0,0 +1,19 @@ +{ + "name": "plugindev", + "name_zh": "插件开发工具链", + "name_en": "Plugin Dev Toolchain", + "version": "1.0.0", + "description": "把 hmapdev 工具链封装成 Agent 可调用的工具:脚手架生成插件工程、构建打包 .hmap、管理 SDK 版本。配合 plugin_install 即可让 Agent 自己做完「新建插件 → 构建 → 安装」全流程。", + "author": "HomeAgent", + "entry": "plugin.so", + "tags": [ + "plugindev", + "toolchain", + "developer" + ], + "targets": "linux/amd64", + "outdir": "dist", + "bundle": true, + "replaces": {}, + "source_dirs": [] +} diff --git a/example/plugindev/plugin.go b/example/plugindev/plugin.go new file mode 100644 index 0000000..f66beea --- /dev/null +++ b/example/plugindev/plugin.go @@ -0,0 +1,455 @@ +package main + +// plugindev:把 SDK 的 hmapdev 工具链封装成 Agent 可调用的插件。 +// +// 为什么需要它:hmapdev 是"给人和 CI 用"的命令行工具。做成插件后,Agent 能自己: +// plugindev_init(脚手架)→ plugindev_build(构建出 .hmap)→ plugin_install(安装)→ plgreload +// 也就是"让 Agent 自己写/改/装插件"这条链不需要人来敲命令。 +// +// 安全边界(都在实现里落实,不只写在描述里): +// - 只有 **hmapdev 一个可执行文件**会被 exec(不接受任意命令/参数拼接); +// - `plugindev_build` 只接受"看起来是插件工程"的目录(含 plg.json), +// 避免把一个 `hmapdev build` 变成对任意目录的操作; +// - `plugindev_init` 生成的工程名必须满足 `[a-zA-Z0-9_-]{1,64}`(与 LLM 函数名 +// 同一套约束 —— 插件名会进 `output_send__<通道>` 之类的工具名); +// - 所有子进程都有超时,输出截断后再返回(防止把几十 MB 构建日志灌进模型上下文)。 + +import ( + "context" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +const ( + defaultBuildTimeout = 10 * time.Minute + maxOutputChars = 6000 +) + +// namePattern 与内核/上游对"会进工具名的标识符"的约束一致。 +var namePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`) + +type Plugin struct { + name string + sdk *sdk.PluginSDK + + hmapdev string // 解析到的 hmapdev 可执行文件路径 + workspace string // 默认工作区(生成的工程落在这里) + timeout time.Duration // 单次 hmapdev 调用的超时 +} + +func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { + return &Plugin{name: name}, nil +} + +func (p *Plugin) Name() string { return p.name } + +func (p *Plugin) Start(s *sdk.PluginSDK) error { + p.sdk = s + s.SetAutoRestart(true) + + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "hmapdev_path", Type: "string", DisplayName: "hmapdev 路径", + Description: "插件开发工具链可执行文件路径。留空则按 PATH → /usr/local/bin/hmapdev → /root/go/bin/hmapdev 查找", + Category: p.name, + }) + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "workspace_dir", Type: "string", DisplayName: "工程工作区", + Description: "plugindev_init 生成工程的默认目录。留空则用 /plugindev", + Category: p.name, + }) + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "build_timeout_sec", Default: 600, Type: "int", DisplayName: "构建超时(秒)", + Description: "单次 hmapdev 调用的超时上限", + Category: p.name, + }) + + p.hmapdev = p.resolveHmapdev() + p.workspace = p.resolveWorkspace() + p.timeout = defaultBuildTimeout + if v, _ := s.Settings().Get("build_timeout_sec"); v != nil { + if n, ok := toInt(v); ok && n > 0 { + p.timeout = time.Duration(n) * time.Second + } + } + + s.RegisterTool("plugindev_status", sdk.ToolDef{ + Name: "plugindev_status", + Description: "查看插件开发工具链状态:hmapdev 是否可用、版本、当前 SDK 版本与路径、工程工作区目录。排查\"为什么不能构建插件\"时先用它。", + Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}, + }, p.handleStatus) + + s.RegisterTool("plugindev_init", sdk.ToolDef{ + Name: "plugindev_init", + Description: "生成一个新的插件工程骨架(等价于 `hmapdev init [--lua]`)。生成后在返回的目录里改 plugin.go,再用 plugindev_build 构建。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", + "description": "插件名(也是工程目录名):只允许字母数字下划线短横,长度 1-64。例:my_plugin", + }, + "lang": map[string]interface{}{ + "type": "string", "description": "go(默认)或 lua", + }, + "dir": map[string]interface{}{ + "type": "string", "description": "在哪个目录下生成(默认工作区)。必须是已存在的目录", + }, + }, + "required": []string{"name"}, + }, + }, p.handleInit) + + s.RegisterTool("plugindev_build", sdk.ToolDef{ + Name: "plugindev_build", + Description: "构建并打包一个插件工程(等价于在该工程目录里执行 `hmapdev build [target]`),产物是 dist/*.hmap。构建成功后用 plugin_install 安装(本地路径)。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "dir": map[string]interface{}{ + "type": "string", "description": "插件工程目录(必须含 plg.json)", + }, + "target": map[string]interface{}{ + "type": "string", "description": "构建目标,留空 = native(当前平台)。例:linux/amd64", + }, + }, + "required": []string{"dir"}, + }, + }, p.handleBuild) + + s.RegisterTool("plugindev_sdk", sdk.ToolDef{ + Name: "plugindev_sdk", + Description: "管理插件 SDK 版本(hmapdev sdk 子命令):list 列出已安装、current 当前版本、path 当前路径、latest 远端最新、install 安装某版本(可用 from 指定本地源码目录)、use 切换版本。构建插件报\"SDK 缺少某能力\"时用它升级 SDK。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "action": map[string]interface{}{ + "type": "string", "description": "list | current | path | latest | install | use", + }, + "version": map[string]interface{}{ + "type": "string", "description": "install/use 的版本号,如 v1.3.0", + }, + "from": map[string]interface{}{ + "type": "string", "description": "install 时用本地 SDK 源码目录(开发中的 SDK 用这个)", + }, + }, + "required": []string{"action"}, + }, + }, p.handleSDK) + + s.RegisterTool("plugindev_projects", sdk.ToolDef{ + Name: "plugindev_projects", + Description: "列出工作区里已有的插件工程(名字、版本、是否已构建出 dist 产物),用于接续之前的开发。", + Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}, + }, p.handleProjects) + + log.Printf("[plugindev] 就绪:hmapdev=%s 工作区=%s", fallback(p.hmapdev, "(未找到)"), p.workspace) + return nil +} + +func (p *Plugin) Stop() error { return nil } + +// ---------------- 工具实现 ---------------- + +func (p *Plugin) handleStatus(args map[string]interface{}) (interface{}, error) { + out := map[string]interface{}{ + "hmapdev": fallback(p.hmapdev, ""), + "workspace": p.workspace, + } + if p.hmapdev == "" { + out["available"] = false + out["hint"] = "未找到 hmapdev。请安装:go build -o /usr/local/bin/hmapdev /tools/hmapdev" + return out, nil + } + out["available"] = true + if txt, err := p.run(nil, ""); err == nil { + out["version"] = strings.TrimSpace(txt) + } else { + out["error"] = err.Error() + } + if txt, err := p.run([]string{"sdk", "current"}, ""); err == nil { + out["sdk_current"] = strings.TrimSpace(txt) + } + if txt, err := p.run([]string{"sdk", "path"}, ""); err == nil { + out["sdk_path"] = strings.TrimSpace(txt) + } + return out, nil +} + +func (p *Plugin) handleInit(args map[string]interface{}) (interface{}, error) { + name, _ := args["name"].(string) + name = strings.TrimSpace(name) + if !namePattern.MatchString(name) { + return map[string]interface{}{ + "error": "插件名只允许 [a-zA-Z0-9_-],长度 1-64(它会进 LLM 工具名,违规会让整条请求被上游拒绝)", + }, nil + } + dir, _ := args["dir"].(string) + if dir == "" { + dir = p.workspace + } + if st, err := os.Stat(dir); err != nil || !st.IsDir() { + return map[string]interface{}{"error": fmt.Sprintf("目录不存在: %s", dir)}, nil + } + cmd := []string{"init", name} + if lang, _ := args["lang"].(string); strings.EqualFold(lang, "lua") { + cmd = append(cmd, "--lua") + } + txt, err := p.run(cmd, dir) + res := map[string]interface{}{"output": txt, "project_dir": filepath.Join(dir, name)} + if err != nil { + res["error"] = err.Error() + } + return res, nil +} + +func (p *Plugin) handleBuild(args map[string]interface{}) (interface{}, error) { + dir, _ := args["dir"].(string) + if dir == "" { + return map[string]interface{}{"error": "dir 不能为空"}, nil + } + abs, err := filepath.Abs(dir) + if err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + // 只在"插件工程"里构建:必须存在 plg.json。这样这个工具不会变成对任意目录跑构建。 + manifest := filepath.Join(abs, "plg.json") + if _, err := os.Stat(manifest); err != nil { + return map[string]interface{}{ + "error": fmt.Sprintf("%s 不是插件工程(缺 plg.json);用 plugindev_init 先建一个", abs), + }, nil + } + cmd := []string{"build"} + if target, _ := args["target"].(string); strings.TrimSpace(target) != "" { + cmd = append(cmd, strings.TrimSpace(target)) + } + txt, runErr := p.run(cmd, abs) + res := map[string]interface{}{"output": txt, "project_dir": abs} + if pkgs := listHmap(filepath.Join(abs, "dist")); len(pkgs) > 0 { + res["artifacts"] = pkgs + res["next"] = "用 plugin_install 安装本地产物(path 指向上面 artifacts 里的 .hmap),然后 plgreload" + } + if runErr != nil { + res["error"] = runErr.Error() + } + return res, nil +} + +func (p *Plugin) handleSDK(args map[string]interface{}) (interface{}, error) { + action, _ := args["action"].(string) + action = strings.TrimSpace(action) + switch action { + case "list", "current", "path", "latest": + txt, err := p.run([]string{"sdk", action}, "") + res := map[string]interface{}{"output": txt} + if err != nil { + res["error"] = err.Error() + } + return res, nil + case "install": + version, _ := args["version"].(string) + from, _ := args["from"].(string) + cmd := []string{"sdk", "install"} + if strings.TrimSpace(from) != "" { + cmd = append(cmd, "--from", strings.TrimSpace(from)) + } + if strings.TrimSpace(version) != "" { + cmd = append(cmd, strings.TrimSpace(version)) + } else if strings.TrimSpace(from) == "" { + cmd = append(cmd, "latest") + } + txt, err := p.run(cmd, "") + res := map[string]interface{}{"output": txt} + if err != nil { + res["error"] = err.Error() + } + return res, nil + case "use": + version, _ := args["version"].(string) + if strings.TrimSpace(version) == "" { + return map[string]interface{}{"error": "use 需要 version"}, nil + } + txt, err := p.run([]string{"sdk", "use", strings.TrimSpace(version)}, "") + res := map[string]interface{}{"output": txt} + if err != nil { + res["error"] = err.Error() + } + return res, nil + default: + return map[string]interface{}{"error": "action 只能是 list/current/path/latest/install/use"}, nil + } +} + +func (p *Plugin) handleProjects(args map[string]interface{}) (interface{}, error) { + entries, err := os.ReadDir(p.workspace) + if err != nil { + return map[string]interface{}{"error": err.Error(), "workspace": p.workspace}, nil + } + var out []map[string]interface{} + for _, e := range entries { + if !e.IsDir() { + continue + } + dir := filepath.Join(p.workspace, e.Name()) + projects := []string{dir} + // 有些工程会被生成到子目录里(hmapdev init 支持指定目录),这里只看一层 + for _, sub := range projects { + if _, err := os.Stat(filepath.Join(sub, "plg.json")); err != nil { + continue + } + item := map[string]interface{}{"name": e.Name(), "dir": sub} + if v := readPlgVersion(filepath.Join(sub, "plg.json")); v != "" { + item["version"] = v + } + if pkgs := listHmap(filepath.Join(sub, "dist")); len(pkgs) > 0 { + item["artifacts"] = pkgs + } + out = append(out, item) + } + } + sort.Slice(out, func(i, j int) bool { return out[i]["name"].(string) < out[j]["name"].(string) }) + return map[string]interface{}{"workspace": p.workspace, "projects": out}, nil +} + +// ---------------- 基础设施 ---------------- + +// run 执行一次 hmapdev。args 为空时执行 `hmapdev version`(用于探活)。 +func (p *Plugin) run(args []string, dir string) (string, error) { + if p.hmapdev == "" { + return "", fmt.Errorf("未找到 hmapdev 可执行文件") + } + ctx, cancel := context.WithTimeout(context.Background(), p.timeout) + defer cancel() + cmd := exec.CommandContext(ctx, p.hmapdev, args...) + if dir != "" { + cmd.Dir = dir + } + // 继承环境(Go 工具链需要 GOCACHE/GOPATH/PATH 等)。 + out, err := cmd.CombinedOutput() + txt := truncateOutput(string(out)) + if ctx.Err() == context.DeadlineExceeded { + return txt, fmt.Errorf("hmapdev %s 超时(%s)", strings.Join(args, " "), p.timeout) + } + if err != nil { + return txt, fmt.Errorf("hmapdev %s 失败: %v", strings.Join(args, " "), err) + } + return txt, nil +} + +// resolveHmapdev 依次尝试:配置项 → PATH → 常见安装位置。 +func (p *Plugin) resolveHmapdev() string { + if v, _ := p.sdk.Settings().Get("hmapdev_path"); v != nil { + if s, _ := v.(string); strings.TrimSpace(s) != "" { + if _, err := os.Stat(strings.TrimSpace(s)); err == nil { + return strings.TrimSpace(s) + } + } + } + if path, err := exec.LookPath("hmapdev"); err == nil { + return path + } + for _, cand := range []string{"/usr/local/bin/hmapdev", "/root/go/bin/hmapdev"} { + if _, err := os.Stat(cand); err == nil { + return cand + } + } + return "" +} + +// resolveWorkspace:配置项 → /plugindev → ./plugindev。 +func (p *Plugin) resolveWorkspace() string { + if v, _ := p.sdk.Settings().Get("workspace_dir"); v != nil { + if s, _ := v.(string); strings.TrimSpace(s) != "" { + ws := strings.TrimSpace(s) + _ = os.MkdirAll(ws, 0o755) + return ws + } + } + if v, err := p.sdk.Settings().GetCore("core.daemon.data_dir"); err == nil { + if dd, _ := v.(string); dd != "" { + ws := filepath.Join(dd, "plugindev") + _ = os.MkdirAll(ws, 0o755) + return ws + } + } + ws := "plugindev" + _ = os.MkdirAll(ws, 0o755) + return ws +} + +// truncateOutput 截断长输出:构建日志动辄几百 KB,直接返回会灌爆模型上下文。 +func truncateOutput(s string) string { + if len(s) <= maxOutputChars { + return s + } + head := s[:maxOutputChars/2] + tail := s[len(s)-maxOutputChars/2:] + return fmt.Sprintf("%s\n…(输出被截断,共 %d 字节)…\n%s", head, len(s), tail) +} + +// listHmap 列出目录下的 .hmap 产物(按名字排序,稳定输出)。 +func listHmap(dir string) []string { + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + var out []string + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".hmap") { + continue + } + out = append(out, filepath.Join(dir, e.Name())) + } + sort.Strings(out) + return out +} + +func readPlgVersion(path string) string { + b, err := os.ReadFile(path) + if err != nil { + return "" + } + s := string(b) + i := strings.Index(s, `"version"`) + if i < 0 { + return "" + } + rest := s[i:] + j := strings.Index(rest, ":") + if j < 0 { + return "" + } + rest = strings.TrimSpace(rest[j+1:]) + rest = strings.TrimPrefix(rest, `"`) + if k := strings.Index(rest, `"`); k > 0 { + return rest[:k] + } + return "" +} + +func toInt(v interface{}) (int, bool) { + switch n := v.(type) { + case int: + return n, true + case int64: + return int(n), true + case float64: + return int(n), true + } + return 0, false +} + +func fallback(s, def string) string { + if strings.TrimSpace(s) == "" { + return def + } + return s +}