refactor(toolchain)!: 工具链 plugindev 更名为 hmapdev,module path 改回 gitcode

- 目录 tools/plugindev → tools/hmapdev,可执行文件名/平台产物名同步
  (hmapdev_linux_amd64 等;包格式仍叫 .hmap)
- module path github.com/JianFeeeee/homeagent-sdk/tools/... → gitcode.com/...
  (与仓库实际托管一致;核心仓不依赖该 path,改动无外部影响)
- SDK 存储目录 ~/.homeagent/plugindev/sdk → ~/.homeagent/hmapdev/sdk
  新目录不存在而旧目录存在时沿用旧目录 → 已装 SDK 版本不会丢失
- 命令表/usage/--help/生成项目 README/示例 README/NSIS 安装器/
  package/build.sh/build-examples.sh 全部同步;PLUGINDEV 环境变量保留兼容
- sdk/ 目录零改动(公开接口不变)

验证:
- go build ./... ok;go test ./tools/hmapdev/ ok(含模板接线守卫 TestProcTemplate_CoversAllCoreMethods)
- bash -n package/{build,build-examples}.sh ok
- 端到端:hmapdev init demo && hmapdev build → dist/demo_bundle.hmap(linux+darwin)
- 本机安装 /usr/local/bin/hmapdev,旧名以软链保留;sdk list/current 正常
This commit is contained in:
JianFeeeee
2026-09-12 12:30:20 +08:00
parent 69ff3089a4
commit b237787c90
33 changed files with 135 additions and 122 deletions

830
tools/hmapdev/cmd_build.go Normal file
View File

@ -0,0 +1,830 @@
package main
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
)
type BuildConfig struct {
OutDir string
Targets []string
Bundle bool
SDKPath string
Replaces []string
}
// buildFailed 记录本次构建是否有平台失败。
//
// 为什么要它:这两个构建函数遇到错误只是 Printf 后 return而 cmdBuild 返回
// void于是**构建失败却以 0 退出**。调用方批量重编脚本、CI、发版脚本
// 只能靠翻日志发现失败——实测中一个示例的 windows 目标编译失败,脚本却报
// 「17/17 全绿」,并因此少产出 16 个 .hmap。
// 判成功要看退出码,不能靠人读日志。
var buildFailed bool
func cmdBuild(args []string) {
// 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", outDir, "")
return
}
// Ensure go.mod exists with correct SDK path
sdkModule := ensureGoMod(plg, sdkPath)
// 保证 SDK 模块可解析,否则编译必死在 "missing go.sum entry"。
if sdkModule != "" {
ensureSDKResolvable(plg, sdkModule, 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)
} else {
for _, t := range targets {
buildTarget(plg, t, outDir, sdkPath)
}
}
// 以非零码退出调用方批量重编、CI、发版脚本靠退出码判成败。
// 以前这里直接 return失败也退 0于是「构建失败」只能靠人翻日志发现——
// 实测中就因此把一次部分失败当成了全绿。
if buildFailed {
fmt.Println("error: 至少一个目标构建失败(详见上面日志)")
os.Exit(1)
}
}
// allBundleTargets 是 --bundle 模式构建的全部平台。
//
// 子进程模式下各平台产物同名plugin.bin——进程边界即 ABI 边界,
// 不存在平台特有扩展名,故 zip 内按平台加后缀区分;
// 内核安装时按当前平台挑对应条目重命名为 plugin.bin。
//
// **不含 windows**:插件只能运行在 homed 能跑的平台上,而 homed 已明确放弃
// Windows 原生支持(插件体系依赖 fd 继承 + 统一共享内存区的段内偏移,
// Windows 句柄模型无法表达。Windows 用户走 WSL2而 WSL2 就是 linux/amd64。
var allBundleTargets = []struct {
target string
entry string // 二进制在 zip 中的文件名
}{
{"linux/amd64", "plugin.bin.linux.amd64"},
{"darwin/amd64", "plugin.bin.darwin.amd64"},
}
// checkTargetSupported 在构建前拦下**已知不支持**的目标,给出可执行的报错。
//
// 为什么要有它:插件运行在 homed 的进程里,所以目标平台必须是 homed 能跑的。
// homed 已放弃 Windows 原生(原因:插件依赖 fd 继承与统一共享内存区段内偏移,
// Windows 句柄模型无法表达),却还去构建 windows 插件,结果是死在一句
// 「undefined: attachUnifiedShm」——看起来像代码 bug实际是平台策略。
// 这里换成明确的结论,并且**不静默跳过**:静默跳过会让人以为产出的包里包含 windows。
func checkTargetSupported(target string) error {
if strings.HasPrefix(target, "windows/") {
return fmt.Errorf("不支持 windows 插件目标:插件运行在 homed 内," +
"而 homed 已放弃 Windows 原生支持(插件体系依赖 fd 继承与统一共享内存区" +
"段内偏移解引用Windows 句柄模型无法表达。Windows 请用 WSL2——" +
"它就是 linux/amd64用 --target linux/amd64 即可")
}
return nil
}
func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
os.MkdirAll(outDir, 0755)
buildDir := "build"
os.MkdirAll(buildDir, 0755)
runtimeCleanup, err := generateProcRuntime()
if err != nil {
fmt.Printf(" error: %v\n", err)
buildFailed = true
return
}
defer runtimeCleanup()
thirdpartCleanup := linkThirdpart(plg, "linux/amd64")
defer thirdpartCleanup()
var binaries []binEntry
for _, bt := range allBundleTargets {
if err := checkTargetSupported(bt.target); err != nil {
fmt.Printf(" error: %v\n", err)
buildFailed = true
return
}
cfg, errMsg := resolveBuild(bt.target)
if cfg == nil {
fmt.Printf(" error: %s\n", errMsg)
buildFailed = true
return
}
// 每平台产物落到独立路径,避免相互覆盖
outName := fmt.Sprintf("%s_%s_%s", cfg.entryFile, cfg.goos, cfg.goarch)
outPath := filepath.Join(buildDir, outName)
// 零 cgo跨平台交叉编译不需目标平台 C 工具链
cmd := exec.Command("go", "build", "-trimpath", "-o", outPath)
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, "GOOS="+cfg.goos, "GOARCH="+cfg.goarch, "CGO_ENABLED=0")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
fmt.Printf(" compiling %s/%s (子进程模式CGO_ENABLED=0)...\n", cfg.goos, cfg.goarch)
if err := cmd.Run(); err != nil {
// 单平台失败即整包失败bundle 少一个平台就是个坏包,
// 却仍会生成 .hmap 让人以为打包成功。
fmt.Printf(" error: build %s/%s: %v\n", cfg.goos, cfg.goarch, err)
buildFailed = true
return
}
binaries = append(binaries, binEntry{src: outPath, zip: bt.entry})
}
// Write plugin.json with all platforms declared
platforms := map[string]bool{}
for _, bt := range allBundleTargets {
parts := strings.SplitN(bt.target, "/", 2)
platforms[parts[0]] = true
}
plats := make([]string, 0, len(platforms))
for p := range platforms {
plats = append(plats, p)
}
writePluginJSON(plg, plats, procEntryFile)
// package single .hmap with correctly named entries
hmapPath := filepath.Join(outDir, fmt.Sprintf("%s_bundle.hmap", toSnake(plg.NameEn)))
createBundleHmap(hmapPath, "plugin.json", binaries)
fmt.Printf(" packaged %s\n", filepath.Base(hmapPath))
}
// IsLua 判断是否为 Lua 插件(走解释器,不经过 Go 编译)。
//
// 这是 entry 字段唯一仍在使用的用途Go 插件不再看 entry 值,一律产出 plugin.bin。
func (p *PlgConfig) IsLua() bool { return p.Entry == luaEntryFile }
func readPlgJSON(path string) (*PlgConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var plg PlgConfig
if err := json.Unmarshal(data, &plg); err != nil {
return nil, err
}
return &plg, nil
}
func parseTargets(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "native" {
return nil
}
var t []string
for _, s := range strings.Split(raw, ",") {
s = strings.TrimSpace(s)
if s != "" {
t = append(t, s)
}
}
return t
}
func writePluginJSON(plg *PlgConfig, platforms []string, entry string) {
m := map[string]interface{}{
"name": plg.Name,
"name_zh": plg.NameZh,
"name_en": plg.NameEn,
"version": plg.Version,
"description": plg.Description,
"author": plg.Author,
"entry": entry,
}
if len(platforms) > 0 {
m["platforms"] = platforms
}
if len(plg.Tags) > 0 {
m["tags"] = plg.Tags
}
data, _ := json.MarshalIndent(m, "", " ")
os.WriteFile("plugin.json", data, 0644)
}
type buildConfig struct {
goos string
goarch string
entryFile string // 一律为 plugin.bin进程边界即 ABI 边界,无平台特有扩展名)
}
// resolveBuild 解析目标平台。
//
// 全平台统一产出 plugin.bin子进程模式下不存在 .so/.dylib/.dll 的区分,
// 因为进程边界本身就是 ABI 边界——这正是三套独立 ABI 实现收敛为
// 单一 RPC 实现的直接后果§9.2Windows 不再是能力退化的第三套实现)。
func resolveBuild(target string) (*buildConfig, string) {
if target == "lua" || target == "" {
return nil, "lua"
}
goos, goarch, _ := strings.Cut(target, "/")
if goos == "" {
goos = runtime.GOOS
if goarch == "" {
goarch = runtime.GOARCH
}
}
switch goos {
case "linux", "darwin", "freebsd", "windows":
return &buildConfig{goos: goos, goarch: goarch, entryFile: procEntryFile}, ""
default:
return nil, fmt.Sprintf("unsupported OS %q", goos)
}
}
// ensureGoMod 确保插件项目的 go.mod 包含 SDK 的 replace 指令。
// 如果 go.mod 不存在或已有正确 replace则跳过。
func ensureGoMod(plg *PlgConfig, sdkPath string) string {
gomodPath := "go.mod"
data, err := os.ReadFile(gomodPath)
if err != nil {
return "" // no go.mod, skip
}
lines := strings.Split(string(data), "\n")
var sdkModule string
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "//") {
continue
}
var mod string
if strings.HasPrefix(line, "require ") {
parts := strings.Fields(line)
if len(parts) >= 2 {
mod = parts[1]
}
} else if !strings.HasPrefix(line, "require") &&
!strings.HasPrefix(line, "module ") &&
!strings.HasPrefix(line, "go ") &&
!strings.HasPrefix(line, "replace ") {
// require 块内行(无前缀)或 import 行
parts := strings.Fields(line)
if len(parts) >= 1 {
mod = parts[0]
}
}
if mod != "" && strings.Contains(mod, "homeagent-sdk") {
sdkModule = mod
break
}
}
if sdkModule == "" {
return ""
}
if sdkPath == "" {
// 仅显式配置plg.json sdk_path 或 --sdk-path才写入 replace
// 避免 go.mod 中出现本地绝对路径。
return sdkModule
}
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.HasPrefix(strings.TrimSpace(line), "replace ") &&
strings.Contains(line, sdkModule) {
parts := strings.Fields(line)
if len(parts) >= 3 && strings.ReplaceAll(parts[2], "\\", "/") == absSDK {
alreadyExists = true
}
continue // strip any existing replace for this module
}
keep = append(keep, line)
}
if alreadyExists {
return sdkModule
}
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)
}
return sdkModule
}
// ensureSDKResolvable 保证 SDK 模块在编译前可解析。
//
// 为何需要这个函数gitcode 的模块不在 proxy.golang.org 上。只要 go.mod
// 里的 SDK 靠 require 版本号解析,而本地又没 go.sum 条目go build 就报
// "missing go.sum entry";而原来那句 `go mod download <mod>` 会去公共 proxy
// 拉一个永远拉不到的条目,超时后只打一行 warn 就继继编译,紧接着死在
// 同一个错误上——新用户拿到的是两段无关的报错。
//
// 三级策略,按代价递增:
// 1. go.mod 已有指向本地目录的 replace —— 什么都不用做replace 到目录时
// go 不需要也不校验 go.sum
// 2. 能定位到本机 SDK 源码 —— 写入 replace。这是存量项目go.mod 旧、
// 无 replace的救场路径。
// 3. 都不行 —— 跑 `go mod tidy`(带 -mod=mod让它自己去试失败则给
// 可操作的提示而不是让用户去猜。
func ensureSDKResolvable(plg *PlgConfig, sdkModule, sdkPath string) {
data, err := os.ReadFile("go.mod")
if err != nil {
return
}
// 策略 1已有指向本地目录的 replace。
// replace 目标带 / 或 . 开头的才是路径;指向另一个模块的 replace 不算。
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "replace ") || !strings.Contains(line, sdkModule) {
continue
}
parts := strings.Fields(line)
if len(parts) < 4 {
continue
}
target := parts[3]
if strings.HasPrefix(target, ".") || strings.HasPrefix(target, "/") ||
strings.Contains(target, ":/") || strings.Contains(target, ":\\") {
return // 已指向本地目录,无需 go.sum
}
}
// 策略 2能定位到本机 SDK 就写 replace。
// resolveSDKPath 失败会 os.Exit所以只在能确定拿到路径时调用它背后的探测。
if root := findLocalSDK(sdkPath); root != "" {
if appendGoModReplace(sdkModule, root) {
fmt.Printf(" SDK 指向本机源码(已写入 go.mod replace%s\n", root)
return
}
}
// 策略 3交给 go mod tidy。
if _, err := os.Stat("go.sum"); err == nil {
return // 已有 go.sum不插手
}
fmt.Println(" 解析 SDK 依赖go mod tidy...")
tidy := exec.Command("go", "mod", "tidy")
tidy.Env = append(os.Environ(), "GOFLAGS=-mod=mod")
if out, err := tidy.CombinedOutput(); err != nil {
fmt.Printf(" warn: go mod tidy 失败:%v\n", err)
if len(out) > 0 {
fmt.Printf(" %s\n", strings.TrimSpace(string(out)))
}
fmt.Printf(" 提示:%s 不在公共 proxy 上。用以下任一方式指向本机 SDK\n", sdkModule)
fmt.Printf(" hmapdev sdk install latest # 装一份到 ~/.homeagent/hmapdev/sdk\n")
fmt.Printf(" hmapdev build --sdk-path <路径> # 或直接指定源码目录\n")
}
}
// findLocalSDK 探测本机 SDK 源码根目录,找不到返回空串。
//
// 与 resolveSDKPath 的区别:后者找不到就 os.Exit适合“必须有”的调用点
// 这里是“有则更好”的探测,不能把构建搞挂。
func findLocalSDK(sdkPath string) string {
candidates := []string{}
if sdkPath != "" {
if abs, err := filepath.Abs(sdkPath); err == nil {
candidates = append(candidates, abs)
}
}
// hmapdev 自身所在位置往上三级tools/hmapdev/hmapdev → SDK 根)
if self, err := os.Executable(); err == nil {
candidates = append(candidates, filepath.Dir(filepath.Dir(filepath.Dir(self))))
}
// hmapdev sdk use 选定的版本(复用 sdkStore(),含改名前的旧目录回退)
store := sdkStore()
if store != "" {
if d, err := os.ReadFile(filepath.Join(store, "current")); err == nil {
if ver := strings.TrimSpace(string(d)); ver != "" {
candidates = append(candidates, filepath.Join(store, ver))
}
}
}
for _, c := range candidates {
if c == "" {
continue
}
if _, err := os.Stat(filepath.Join(c, "sdk", "plugin.go")); err == nil {
return c
}
}
return ""
}
// appendGoModReplace 向 go.mod 追加一条 replace成功返回 true。
func appendGoModReplace(module, localPath string) bool {
data, err := os.ReadFile("go.mod")
if err != nil {
return false
}
abs, err := filepath.Abs(localPath)
if err != nil {
return false
}
abs = strings.ReplaceAll(abs, "\\", "/")
s := strings.TrimRight(string(data), "\r\n")
s += fmt.Sprintf("\n\nreplace %s => %s\n", module, abs)
return os.WriteFile("go.mod", []byte(s), 0644) == nil
}
func resolveSDKPath(sdkPath string) string {
if sdkPath != "" {
abs, _ := filepath.Abs(sdkPath)
if _, err := os.Stat(filepath.Join(abs, "sdk", "plugin.go")); err == nil {
return abs
}
fmt.Printf("error: --sdk-path %q not a valid SDK\n", sdkPath)
os.Exit(1)
}
// Detect from hmapdev's own location (internal dev)
self, err := os.Executable()
if err == nil {
cand := filepath.Dir(filepath.Dir(filepath.Dir(self)))
if _, err := os.Stat(filepath.Join(cand, "sdk", "plugin.go")); err == nil {
return cand
}
}
// Active SDK via hmapdev sdk use
store := sdkStore()
if store != "" {
if d, err := os.ReadFile(filepath.Join(store, "current")); err == nil {
ver := strings.TrimSpace(string(d))
if ver != "" {
root := filepath.Join(store, ver)
if _, err := os.Stat(filepath.Join(root, "sdk", "plugin.go")); err == nil {
return root
}
}
}
}
fmt.Printf("error: cannot locate SDK. Use --sdk-path or 'hmapdev sdk use'\n")
os.Exit(1)
return ""
}
func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) {
os.MkdirAll(outDir, 0755)
// Lua: no compilation, package source directly
if target == "lua" {
writePluginJSON(plg, nil, "main.lua")
pkgFiles := []string{"plugin.json", "main.lua"}
for _, f := range []string{"README.md", "LICENSE"} {
if _, err := os.Stat(f); err == nil {
pkgFiles = append(pkgFiles, f)
}
}
// Include thirdpart Lua files
if entries, err := os.ReadDir("thirdpart"); err == nil {
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".lua") {
path := filepath.Join("thirdpart", e.Name())
if _, err := os.Stat(path); err == nil {
pkgFiles = append(pkgFiles, path)
}
}
}
}
hmapName := fmt.Sprintf("%s_lua.hmap", toSnake(plg.NameEn))
createHmap(filepath.Join(outDir, hmapName), pkgFiles)
fmt.Printf(" packaged %s\n", hmapName)
return
}
// Resolve build config全平台统一产出 plugin.bin
cfg, errMsg := resolveBuild(target)
if cfg == nil {
fmt.Printf(" error: %s\n", errMsg)
return
}
buildDir := "build"
os.MkdirAll(buildDir, 0755)
outPath := filepath.Join(buildDir, cfg.entryFile)
runtimeCleanup, err := generateProcRuntime()
if err != nil {
fmt.Printf(" error: %v\n", err)
return
}
defer runtimeCleanup()
// 已知未实现的目标在编译前拦下,给可执行的报错(见 checkTargetSupported
if err := checkTargetSupported(target); err != nil {
fmt.Printf(" error: %v\n", err)
buildFailed = true
return
}
// Auto-link thirdpart/ contents + source_dirs + replace targets
thirdpartCleanup := linkThirdpart(plg, target)
defer thirdpartCleanup()
// Write plugin.json with the correct entry for this target
writePluginJSON(plg, nil, cfg.entryFile)
// 普通 go build + 零 cgo交叉编译不再需要目标平台的 C 工具链
// (旧路径靠 detectWindowsCC 找 MinGW现在整个问题消失
cmd := exec.Command("go", "build", "-trimpath", "-o", outPath)
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, "GOOS="+cfg.goos, "GOARCH="+cfg.goarch, "CGO_ENABLED=0")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
fmt.Printf(" compiling %s/%s (子进程模式CGO_ENABLED=0)...\n", cfg.goos, cfg.goarch)
if err := cmd.Run(); err != nil {
fmt.Printf(" error: build %s/%s: %v\n", cfg.goos, cfg.goarch, err)
buildFailed = true
return
}
// package
pkgFiles := []string{"plugin.json", outPath}
for _, f := range []string{"README.md", "LICENSE"} {
if _, err := os.Stat(f); err == nil {
pkgFiles = append(pkgFiles, f)
}
}
hmapName := fmt.Sprintf("%s_%s_%s.hmap", toSnake(plg.NameEn), cfg.goos, cfg.goarch)
createHmap(filepath.Join(outDir, hmapName), pkgFiles)
fmt.Printf(" packaged %s\n", hmapName)
}
type binEntry struct {
src string // 磁盘路径,如 build/plugin.bin
zip string // zip 中条目名,如 plugin.bin.linux.amd64
}
// createBundleHmap 创建包含多平台二进制的 bundle .hmap 文件。
// jsonName 是 plugin.json 在 zip 中的条目名;
// binaries 的 src 为磁盘路径zip 为 zip 中的条目名。
func createBundleHmap(hmapPath, jsonName string, binaries []binEntry) {
f, err := os.Create(hmapPath)
if err != nil {
fmt.Printf("error: create hmap %s: %v\n", hmapPath, err)
return
}
defer f.Close()
w := zip.NewWriter(f)
defer w.Close()
// Add plugin.json
writeZipEntry := func(zipName, diskPath string) {
info, err := os.Stat(diskPath)
if err != nil {
return
}
hdr, err := zip.FileInfoHeader(info)
if err != nil {
return
}
hdr.Method = zip.Deflate
hdr.Name = zipName
writer, err := w.CreateHeader(hdr)
if err != nil {
return
}
src, err := os.Open(diskPath)
if err != nil {
return
}
io.Copy(writer, src)
src.Close()
}
writeZipEntry(jsonName, jsonName)
// Add each platform binary with the correct zip entry name
for _, b := range binaries {
if _, err := os.Stat(b.src); err == nil {
writeZipEntry(b.zip, b.src)
}
}
// Add optional metadata files
for _, fname := range []string{"README.md", "LICENSE"} {
if _, err := os.Stat(fname); err == nil {
writeZipEntry(fname, fname)
}
}
}
func createHmap(hmapPath string, files []string) {
f, err := os.Create(hmapPath)
if err != nil {
fmt.Printf("error: create hmap %s: %v\n", hmapPath, err)
return
}
defer f.Close()
w := zip.NewWriter(f)
defer w.Close()
for _, path := range files {
if path == "" {
continue
}
info, err := os.Stat(path)
if err != nil {
continue
}
hdr, err := zip.FileInfoHeader(info)
if err != nil {
continue
}
hdr.Method = zip.Deflate
hdr.Name = filepath.Base(path)
writer, err := w.CreateHeader(hdr)
if err != nil {
continue
}
src, err := os.Open(path)
if err != nil {
continue
}
io.Copy(writer, src)
src.Close()
}
}
func toSnake(s string) string {
return strings.ToLower(strings.ReplaceAll(s, " ", "_"))
}
// stripIncludeGuard strips preprocessor guards and C++ comments from a C header,
// since these can confuse cgo's type resolution.
// 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 target == "lua" {
return func() {}
}
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() {}
}
// 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 _, 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)
}
}
}
// 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") {
hasGo = true
break
}
}
if !hasGo {
continue
}
if !filepath.IsAbs(d) {
importPath := modulePath + "/" + d
stubs = append(stubs, importPath)
} else {
// 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, strings.TrimSpace(from))
break
}
}
}
}
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)
}
}

View File

@ -0,0 +1,33 @@
package main
import (
"fmt"
"os"
)
func cmdClean(args []string) {
outDir := "dist"
if len(args) > 0 && args[0] == "--outdir" && len(args) > 1 {
outDir = args[1]
}
dirs := []string{"build", outDir}
for _, d := range dirs {
if _, err := os.Stat(d); os.IsNotExist(err) {
continue
}
if err := os.RemoveAll(d); err != nil {
fmt.Printf("error: remove %s: %v\n", d, err)
} else {
fmt.Printf("removed %s/\n", d)
}
}
// also clean generated files
for _, f := range []string{"plugin.json", "z_bridge_gen.go"} {
if _, err := os.Stat(f); err == nil {
os.Remove(f)
fmt.Printf("removed %s\n", f)
}
}
}

175
tools/hmapdev/cmd_debug.go Normal file
View File

@ -0,0 +1,175 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"gitcode.com/JianFeeeee/homeagent-sdk/tools/hmapdev/yaegi"
)
// tmplLuaDebug is the temporary Lua debug script template
const tmplLuaDebug = `-- HomeAgent Lua Plugin Debug
-- Generated by hmapdev debug --lua
sdk = require("sdk")
local ok, plugin = pcall(dofile, "main.lua")
if not ok then
print("[debug] ERROR loading main.lua: " .. tostring(plugin))
os.exit(1)
end
if type(plugin) == "table" then
print("[debug] Plugin: " .. tostring(plugin.name or "unnamed"))
if plugin.start then
print("[debug] Calling plugin.start(sdk) ...")
local ok, err = pcall(plugin.start, sdk)
if ok then
print("[debug] plugin.start() OK")
else
print("[debug] plugin:start() ERROR: " .. tostring(err))
end
end
end
print("")
print("=== Interactive REPL ===")
print("sdk, plugin globals are available")
local function repl()
while true do
io.write("> ")
io.flush()
local line = io.read()
if line == nil or line == "exit" or line == "quit" then break end
if line == "help" then
print(" help - this help")
print(" exit/quit - exit debug")
print(" sdk - SDK API table")
print(" plugin - loaded plugin table")
else
local fn, err = (loadstring or load)(line)
if fn then
local ok, result = pcall(fn)
if ok and result ~= nil then print(tostring(result)) end
if not ok then print("Error: " .. tostring(result)) end
else
print("Error: " .. tostring(err))
end
end
end
end
repl()
`
type DebugConfig struct {
Dir string
Replaces []string
}
func cmdDebug(args []string) {
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")
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 hasGo {
debugGo(dir, replaces)
} else {
fmt.Println("error: no main.lua or .go files found in", dir)
os.Exit(1)
}
}
func debugLua(dir, sdkPath, luaPath string) {
// check lua interpreter
luaBin, err := exec.LookPath("lua")
if err != nil {
fmt.Println("error: lua interpreter not found in PATH")
fmt.Println(" install Lua 5.1+ or use hmapdev build to compile your plugin")
os.Exit(1)
}
fmt.Printf("[debug] Lua interpreter: %s\n", luaBin)
fmt.Printf("[debug] Plugin dir: %s\n", dir)
// check if sdk.lua exists
if _, err := os.Stat(sdkPath); os.IsNotExist(err) {
fmt.Println("warning: sdk.lua not found, debug SDK mock will not be available")
}
// write temporary debug script
debugScript := filepath.Join(dir, "_debug.lua")
if err := os.WriteFile(debugScript, []byte(tmplLuaDebug), 0644); err != nil {
fmt.Printf("error: write debug script: %v\n", err)
os.Exit(1)
}
defer os.Remove(debugScript)
cmd := exec.Command(luaBin, filepath.Base(debugScript))
cmd.Dir = dir
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
fmt.Println("[debug] Starting Lua debug session...")
fmt.Println()
if err := cmd.Run(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
os.Exit(exitErr.ExitCode())
}
fmt.Printf("[debug] error: %v\n", err)
os.Exit(1)
}
}
func debugGo(dir string, replaces []string) {
debug, err := yaegi.NewYaegiDebugger(dir, replaces)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
fmt.Printf("[debug] Plugin dir: %s\n", dir)
if err := debug.LoadPlugin(); err != nil {
fmt.Printf("[debug] load plugin: %v\n", err)
os.Exit(1)
}
if err := debug.StartREPL(); err != nil {
fmt.Printf("[debug] repl error: %v\n", err)
os.Exit(1)
}
}

295
tools/hmapdev/cmd_init.go Normal file
View File

@ -0,0 +1,295 @@
package main
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"text/template"
)
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"`
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 {
Plg PlgConfig
IsLua bool
// Go module info (for go.mod)
ModulePath string
GoVersion string
SDKModule string
SDKVersion string
// SDKLocalPath 是本机 SDK 源码绝对路径,写入生成的 go.mod 作为 replace 目标。
//
// 为何必须写gitcode 的模块不在 proxy.golang.org 上,只 require 一个
// 版本号的 go.mod 配上缺失的 go.sum新用户第一次 `hmapdev build`
// 必定死在 "missing go.sum entry",而 `go mod tidy` 又会去公共 proxy 拉
// 一个不存在的条目。有了本地 replacego 完全不需要 go.sum 条目。
SDKLocalPath string
}
func cmdInit(args []string) {
if len(args) < 1 {
fmt.Println("Usage: hmapdev init <name> [--lua] [--type remotedevice]")
os.Exit(1)
}
name := args[0]
isLua := false
isRemoteDevice := false
for _, a := range args[1:] {
switch a {
case "--lua":
isLua = true
case "--type", "-t":
// handled in next iteration
}
}
// also check --type remotedevice as a single arg
for i, a := range args[1:] {
if a == "--type" || a == "-t" {
if i+1 < len(args[1:]) {
if args[1:][i+1] == "remotedevice" {
isRemoteDevice = true
}
}
}
if a == "--type=remotedevice" || a == "-t=remotedevice" {
isRemoteDevice = true
}
}
if isRemoteDevice && isLua {
fmt.Println("error: --type remotedevice and --lua are mutually exclusive")
os.Exit(1)
}
// Remote device projects use different scaffold
if isRemoteDevice {
scaffoldRemoteDevice(name)
return
}
dir := name
if _, err := os.Stat(dir); !os.IsNotExist(err) {
fmt.Printf("error: directory %q already exists\n", dir)
os.Exit(1)
}
// Go 插件统一产出 plugin.binv1.0.0 子进程模式)。
//
// 此前这里写 "plugin.so"scaffold 出来的 plg.json 就带着一个已退场的
// entry 值,新手跟着模板走会误以为自己在做 C ABI 插件。
// build 实际不看这个值(只用它区分 Lua但模板不应误导。
entry := "plugin.bin"
var targets string
if isLua {
entry = "main.lua"
targets = "lua"
} else {
targets = "linux/amd64,windows/amd64"
}
nameEn := strings.ReplaceAll(name, "-", " ")
nameEn = strings.Title(nameEn)
data := TemplateData{
Plg: PlgConfig{
Name: name,
NameZh: "中文名",
NameEn: nameEn,
Version: "0.1.0",
Description: name + " plugin",
Author: "HomeAgent",
Entry: entry,
Tags: []string{name},
Targets: targets,
},
IsLua: isLua,
}
// Detect SDK info for Go plugin go.mod.
// 生成的 go.mod 除 require 外还写一条指向本机 SDK 的 replace
// 否则 scaffold 出来的项目第一次 build 必定失败(详见 SDKLocalPath 注释)。
if !isLua {
sdkMod, goVer, sdkRoot, sdkVer := detectSDKInfo()
data.ModulePath = name
data.GoVersion = goVer
data.SDKModule = sdkMod
data.SDKVersion = "v" + sdkVer
data.SDKLocalPath = strings.ReplaceAll(sdkRoot, "\\", "/")
}
if err := os.MkdirAll(dir, 0755); err != nil {
fmt.Printf("error: create dir: %v\n", err)
os.Exit(1)
}
// write plg.json
writeTemplate(filepath.Join(dir, "plg.json"), tmplPlgJSON, data)
// Lua plugins get main.lua + sdk.lua; Go plugins get plugin.go only
if isLua {
writeTemplate(filepath.Join(dir, "main.lua"), tmplMainLua, data)
writeTemplate(filepath.Join(dir, "sdk.lua"), tmplSDKLua, data)
} else {
writeTemplate(filepath.Join(dir, "plugin.go"), tmplPluginGo, data)
}
// write README.md
writeTemplate(filepath.Join(dir, "README.md"), tmplReadme, data)
// write go.mod for Go plugins
if !isLua {
writeTemplate(filepath.Join(dir, "go.mod"), tmplGoMod, data)
}
// create thirdpart directory for external library sources
os.MkdirAll(filepath.Join(dir, "thirdpart"), 0755)
fmt.Printf("Created plugin project %q (%s)\n", dir, entry)
if isLua {
fmt.Printf(" cd %s && lua main.lua (standalone test)\n", dir)
}
fmt.Printf(" cd %s && hmapdev build\n", dir)
}
// detectSDKInfo reads the HomeAgent SDK's go.mod and meta to get module path, go version, and SDK version.
func detectSDKInfo() (modulePath, goVersion, sdkPath, sdkVersion string) {
root := activeSDKRoot()
gomodPath := filepath.Join(root, "go.mod")
data, err := os.ReadFile(gomodPath)
if err != nil {
fmt.Printf("error: cannot read SDK go.mod at %s: %v\n", gomodPath, err)
os.Exit(1)
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "module ") {
modulePath = strings.TrimSpace(line[7:])
}
if strings.HasPrefix(line, "go ") {
goVersion = strings.TrimSpace(line[3:])
}
}
if modulePath == "" {
fmt.Printf("error: no module directive in %s\n", gomodPath)
os.Exit(1)
}
if goVersion == "" {
goVersion = "1.21"
}
sdkVersion = readMetaVersion(root)
return modulePath, goVersion, root, sdkVersion
}
// scaffoldRemoteDevice 创建远程设备适配器项目脚手架
func scaffoldRemoteDevice(name string) {
dir := name
if _, err := os.Stat(dir); !os.IsNotExist(err) {
fmt.Printf("error: directory %q already exists\n", dir)
os.Exit(1)
}
nameEn := strings.Title(strings.ReplaceAll(name, "-", " "))
data := TemplateData{
Plg: PlgConfig{
Name: name,
NameZh: "中文名",
NameEn: nameEn,
Version: "0.1.0",
Description: name + " remote device adapter",
Author: "HomeAgent",
Entry: name,
Tags: []string{name, "remotedevice"},
},
}
if err := os.MkdirAll(dir, 0755); err != nil {
fmt.Printf("error: create dir: %v\n", err)
os.Exit(1)
}
// 写入 main.c
writeTemplate(filepath.Join(dir, "main.c"), tmplRemoteDeviceMain, data)
// 写入 CMakeLists.txt
writeTemplate(filepath.Join(dir, "CMakeLists.txt"), tmplRemoteDeviceCMake, data)
// 创建 SDK 目录symlink/copy
sdkSrc := filepath.Join("..", "remotedevice")
sdkDst := filepath.Join(dir, "ha_remotedevice")
if _, err := os.Stat(sdkDst); os.IsNotExist(err) {
// 尝试创建符号链接,失败则提示
if err := os.Symlink(sdkSrc, sdkDst); err != nil {
fmt.Printf(" note: could not create symlink to SDK, copy manually:\n")
fmt.Printf(" cp -r %s %s\n", sdkSrc, sdkDst)
}
}
fmt.Printf("Created remote device adapter project %q\n", dir)
fmt.Printf(" cd %s && mkdir build && cd build && cmake .. && make\n", dir)
fmt.Printf(" Or include as subdirectory in your project:\n")
fmt.Printf(" add_subdirectory(%s)\n", dir)
}
func writeTemplate(path, content string, data TemplateData) {
tmpl, err := template.New("").Parse(content)
if err != nil {
fmt.Printf("error: parse template: %v\n", err)
os.Exit(1)
}
f, err := os.Create(path)
if err != nil {
fmt.Printf("error: create %s: %v\n", path, err)
os.Exit(1)
}
defer f.Close()
if err := tmpl.Execute(f, data); err != nil {
fmt.Printf("error: execute template: %v\n", err)
os.Exit(1)
}
}

524
tools/hmapdev/cmd_sdk.go Normal file
View File

@ -0,0 +1,524 @@
package main
import (
"archive/tar"
"compress/gzip"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
)
const sdkDirName = "hmapdev/sdk"
// legacySDKDirName 是改名前的存储目录。工具链在 1.2.0 从 plugindev 改名 hmapdev
// 已装过旧版的机器上 SDK 仍在旧路径,直接换名会让它找不到已装 SDK
// (表现为「没有活动版本」)。新目录不存在而旧目录存在时沿用旧目录。
const legacySDKDirName = "plugindev/sdk"
// sdkStore returns the root directory for stored SDK versions.
func sdkStore() string {
if v := os.Getenv("HOMEAGENT_SDK_DIR"); v != "" {
return v
}
home, err := os.UserHomeDir()
if err != nil {
fmt.Printf("error: cannot determine home directory: %v\n", err)
os.Exit(1)
}
dir := filepath.Join(home, ".homeagent", sdkDirName)
if _, err := os.Stat(dir); err != nil {
if legacy := filepath.Join(home, ".homeagent", legacySDKDirName); legacy != "" {
if _, err := os.Stat(legacy); err == nil {
return legacy
}
}
}
return dir
}
func sdkCurrentDir() string {
return filepath.Join(sdkStore(), "current")
}
func sdkVersionDir(version string) string {
return filepath.Join(sdkStore(), version)
}
const sdkRepoURL = "https://gitcode.com/JianFeeeee/homeagent-sdk.git"
const sdkDownloadURL = "https://gitcode.com/JianFeeeee/homeagent-sdk/-/archive/%s/homeagent-sdk-%s.tar.gz"
func cmdSDK(args []string) {
if len(args) < 1 {
sdkHelp()
return
}
switch args[0] {
case "list":
cmdSDKList()
case "install":
if len(args) < 2 {
fmt.Println("Usage: hmapdev sdk install <version>")
os.Exit(1)
}
cmdSDKInstall(args[1])
case "use":
if len(args) < 2 {
fmt.Println("Usage: hmapdev sdk use <version>")
os.Exit(1)
}
cmdSDKUse(args[1])
case "path":
cmdSDKPath()
case "current":
cmdSDKCurrent()
case "latest":
cmdSDKLatest()
default:
sdkHelp()
}
}
func sdkHelp() {
fmt.Print(`Usage: hmapdev sdk <command>
Manage installed HomeAgent SDK versions.
Commands:
list List installed SDK versions
install <version> Download and install an SDK version (tag or branch)
use <version> Switch to the specified SDK version for new projects
path Show the current active SDK directory
current Show the current active SDK version
latest Show the latest available version from remote
Examples:
hmapdev sdk install v0.7.1
hmapdev sdk install latest
hmapdev sdk use v0.7.1
`)
}
// cmdSDKList lists installed SDK versions.
func cmdSDKList() {
store := sdkStore()
entries, err := os.ReadDir(store)
if err != nil {
if os.IsNotExist(err) {
fmt.Println("No SDK versions installed.")
return
}
fmt.Printf("error: read SDK store %s: %v\n", store, err)
os.Exit(1)
}
current := resolveCurrentVersion(store)
var versions []string
for _, e := range entries {
if e.IsDir() && e.Name() != "current" && !strings.HasPrefix(e.Name(), ".") {
versions = append(versions, e.Name())
}
}
sort.Sort(sort.Reverse(sort.StringSlice(versions)))
if len(versions) == 0 {
fmt.Println("No SDK versions installed.")
return
}
fmt.Println("Installed SDK versions:")
for _, v := range versions {
mark := " "
if v == current {
mark = "*"
}
fmt.Printf(" %s %s\n", mark, v)
}
if current == "" {
fmt.Println("\nNo version active. Use 'hmapdev sdk use <version>' to set one.")
}
}
// cmdSDKInstall downloads and installs an SDK version from Release archive.
func cmdSDKInstall(version string) {
store := sdkStore()
if err := os.MkdirAll(store, 0755); err != nil {
fmt.Printf("error: create SDK store %s: %v\n", store, err)
os.Exit(1)
}
if version == "latest" {
tag, err := fetchLatestTag()
if err != nil {
fmt.Printf("error: fetch latest tag: %v\n", err)
os.Exit(1)
}
version = tag
fmt.Printf("Latest version: %s\n", version)
}
dest := sdkVersionDir(version)
if _, err := os.Stat(dest); err == nil {
fmt.Printf("SDK version %s already installed at %s\n", version, dest)
return
}
url := fmt.Sprintf(sdkDownloadURL, version, version)
fmt.Printf("Downloading SDK %s from Release archive...\n", version)
tmpDir, err := os.MkdirTemp("", "homeagent-sdk-extract-*")
if err != nil {
fmt.Printf("error: create temp dir: %v\n", err)
os.Exit(1)
}
defer os.RemoveAll(tmpDir)
if err := installFromArchive(url, tmpDir); err != nil {
fmt.Printf("warn: archive download failed (%v), falling back to git clone...\n", err)
if err := installFromGit(version, tmpDir); err != nil {
fmt.Printf("error: install SDK %s: %v\n", version, err)
os.Exit(1)
}
}
if err := os.Rename(tmpDir, dest); err != nil {
// 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)
// Auto-switch to newly installed version if no version is currently active
if resolveCurrentVersion(store) == "" {
setCurrentVersion(store, version)
}
}
func openFile(path string) (*os.File, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
return f, nil
}
// installFromArchive downloads the SDK release archive and extracts it to tmpDir.
func installFromArchive(url, tmpDir string) error {
tmpFile, err := os.CreateTemp("", "homeagent-sdk-*.tar.gz")
if err != nil {
return err
}
tmpPath := tmpFile.Name()
defer os.Remove(tmpPath)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download SDK %s: HTTP %d", url, resp.StatusCode)
}
if _, err := io.Copy(tmpFile, resp.Body); err != nil {
return err
}
tmpFile.Close()
f, err := openFile(tmpPath)
if err != nil {
return err
}
gzr, err := gzip.NewReader(f)
if err != nil {
f.Close()
return err
}
defer gzr.Close()
defer f.Close()
tr := tar.NewReader(gzr)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
// Strip top-level directory from archive path
parts := strings.SplitN(header.Name, "/", 2)
if len(parts) < 2 {
continue
}
relPath := parts[1]
if relPath == "" {
continue
}
target := filepath.Join(tmpDir, relPath)
switch header.Typeflag {
case tar.TypeDir:
os.MkdirAll(target, os.FileMode(header.Mode))
case tar.TypeReg:
os.MkdirAll(filepath.Dir(target), 0755)
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY, os.FileMode(header.Mode))
if err != nil {
return err
}
if _, err := io.Copy(f, tr); err != nil {
f.Close()
return err
}
f.Close()
}
}
return nil
}
// installFromGit clones the SDK repo at the given tag/branch into tmpDir.
func installFromGit(version, tmpDir string) error {
cmd := exec.Command("git", "clone", "--depth", "1", "--branch", version, sdkRepoURL, tmpDir)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("git clone: %v", err)
}
return os.RemoveAll(filepath.Join(tmpDir, ".git"))
}
// cmdSDKUse switches the active SDK version.
func cmdSDKUse(version string) {
store := sdkStore()
verDir := sdkVersionDir(version)
if _, err := os.Stat(verDir); os.IsNotExist(err) {
fmt.Printf("SDK version %s is not installed.\n", version)
fmt.Printf("Install it first: hmapdev sdk install %s\n", version)
os.Exit(1)
}
setCurrentVersion(store, version)
fmt.Printf("Active SDK version set to %s\n", version)
}
// cmdSDKPath prints the active SDK directory.
func cmdSDKPath() {
store := sdkStore()
current := resolveCurrentVersion(store)
if current == "" {
fmt.Println("No active SDK version set.")
fmt.Println("Use 'hmapdev sdk use <version>' to set one.")
os.Exit(1)
}
fmt.Println(sdkVersionDir(current))
}
// cmdSDKCurrent prints the active SDK version.
func cmdSDKCurrent() {
store := sdkStore()
current := resolveCurrentVersion(store)
if current == "" {
fmt.Println("No active SDK version set.")
os.Exit(1)
}
fmt.Println(current)
}
// cmdSDKLatest fetches the latest tag from the remote SDK repo.
func cmdSDKLatest() {
tag, err := fetchLatestTag()
if err != nil {
fmt.Printf("error: fetch latest tag: %v\n", err)
os.Exit(1)
}
fmt.Println(tag)
}
// resolveCurrentVersion reads the current active version.
func resolveCurrentVersion(store string) string {
currentFile := filepath.Join(store, "current")
data, err := os.ReadFile(currentFile)
if err != nil {
return ""
}
v := strings.TrimSpace(string(data))
if v == "" {
return ""
}
verDir := filepath.Join(store, v)
if _, err := os.Stat(verDir); os.IsNotExist(err) {
return ""
}
return v
}
// setCurrentVersion writes the version name to the current file.
func setCurrentVersion(store, version string) {
currentFile := filepath.Join(store, "current")
if err := os.WriteFile(currentFile, []byte(version+"\n"), 0644); err != nil {
fmt.Printf("error: write current version: %v\n", err)
os.Exit(1)
}
}
// fetchLatestTag uses git ls-remote to find the latest semver tag.
func fetchLatestTag() (string, error) {
cmd := exec.Command("git", "ls-remote", "--tags", sdkRepoURL)
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("git ls-remote failed: %w", err)
}
tags := parseTags(string(out))
if len(tags) == 0 {
return "", fmt.Errorf("no tags found in remote repository")
}
return tags[len(tags)-1], nil
}
// parseTags extracts semver tags from git ls-remote output and sorts them.
func parseTags(output string) []string {
seen := make(map[string]bool)
var tags []string
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.Fields(line)
if len(parts) < 2 {
continue
}
ref := parts[1]
// Only refs/tags/v*, skip refs/tags/v*-^{}
if !strings.HasPrefix(ref, "refs/tags/v") || strings.HasSuffix(ref, "^{}") {
continue
}
tag := strings.TrimPrefix(ref, "refs/tags/")
if seen[tag] {
continue
}
seen[tag] = true
tags = append(tags, tag)
}
sort.Slice(tags, func(i, j int) bool {
return compareSemver(tags[i], tags[j]) < 0
})
return tags
}
// compareSemver compares two semver tags (vX.Y.Z). Returns -1, 0, or 1.
func compareSemver(a, b string) int {
an := parseSemver(a)
bn := parseSemver(b)
for i := 0; i < 3; i++ {
if an[i] != bn[i] {
if an[i] < bn[i] {
return -1
}
return 1
}
}
return 0
}
// 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 := 0; i < 3 && i < len(parts); i++ {
n := 0
fmt.Sscanf(parts[i], "%d", &n)
v[i] = n
}
return v
}
// activeSDKRoot returns the path to the active SDK root.
// It replaces the old runtime.Caller(0) approach so hmapdev can work
// independently of its own build location.
func activeSDKRoot() string {
store := sdkStore()
current := resolveCurrentVersion(store)
if current == "" {
fmt.Printf("error: no active SDK version set\n")
fmt.Printf(" Install one: hmapdev sdk install latest\n")
fmt.Printf(" Or set one: hmapdev sdk use <version>\n")
os.Exit(1)
}
root := sdkVersionDir(current)
if _, err := os.Stat(root); os.IsNotExist(err) {
fmt.Printf("error: active SDK version %s not found at %s\n", current, root)
fmt.Printf(" Reinstall: hmapdev sdk install %s\n", current)
os.Exit(1)
}
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 {
metaPath := filepath.Join(sdkRoot, "meta", "meta.go")
data, err := os.ReadFile(metaPath)
if err != nil {
return "0.0.0"
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Version = ") {
v := strings.TrimPrefix(line, "Version = ")
v = strings.Trim(v, `"`)
return v
}
}
return "0.0.0"
}

9
tools/hmapdev/go.mod Normal file
View File

@ -0,0 +1,9 @@
module gitcode.com/JianFeeeee/homeagent-sdk/tools/hmapdev
go 1.21.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
require github.com/traefik/yaegi v0.16.1
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

2
tools/hmapdev/go.sum Normal file
View File

@ -0,0 +1,2 @@
github.com/traefik/yaegi v0.16.1 h1:f1De3DVJqIDKmnasUF6MwmWv1dSEEat0wcpXhD2On3E=
github.com/traefik/yaegi v0.16.1/go.mod h1:4eVhbPb3LnD2VigQjhYbEJ69vDRFdT2HQNrXx8eEwUY=

49
tools/hmapdev/main.go Normal file
View File

@ -0,0 +1,49 @@
package main
import (
"fmt"
"os"
)
func main() {
if len(os.Args) < 2 {
help()
return
}
switch os.Args[1] {
case "init":
cmdInit(os.Args[2:])
case "build":
cmdBuild(os.Args[2:])
case "clean":
cmdClean(os.Args[2:])
case "debug":
cmdDebug(os.Args[2:])
case "sdk":
cmdSDK(os.Args[2:])
default:
help()
}
}
func help() {
fmt.Print(`HomeAgent Plugin Dev Tool
Usage:
hmapdev init <name> Scaffold a new plugin project
hmapdev init <name> --lua Create Lua plugin
hmapdev init <name> --type remotedevice
Create C remote device adapter
hmapdev build [flags] Compile and package plugin
hmapdev clean Clean build/dist artifacts
hmapdev debug [dir] Interpret and debug plugin source
hmapdev sdk <command> Manage SDK versions
Flags:
--outdir Output directory (default: dist)
--target Target OS/arch (e.g. linux/amd64), repeatable
--lua Create Lua plugin (for init)
--type Project type: "remotedevice" (for init)
-t Alias for --type
`)
}

View File

@ -0,0 +1,91 @@
package main
import (
"embed"
"fmt"
"os"
)
// 子进程插件运行时(外部插件多进程化)。
//
// 模板为何是**真实 .go 源文件** + //go:embed而不是 raw string
// 1100+ 行代码塞在字符串里写错只能等生成插件时才炸;作为源文件可被
// gofmt / go vet / go/parser 直接检查proc_runtime_test.go 的 16 项
// 静态检查就以此为前提)。
//
// 构建从 `-buildmode=c-shared` + CGO_ENABLED=1 变成普通 `go build` +
// CGO_ENABLED=0交叉编译不再需要目标平台的 C 工具链§3.1 连带消失项)。
//
// 设计依据docs/zh/架构迁移评估.md §3、docs/zh/plugin-migration-plan.md Part 3/6
//go:embed templates/proc_main.go.tmpl
//go:embed templates/proc_shm_unix.go.tmpl
//go:embed templates/proc_shm_windows.go.tmpl
var procTemplates embed.FS
// procRuntimeFiles 列出生成到插件目录的运行时文件。
//
// 共享段与事件通知的**传递机制**按平台不同Unix 继承 fd
// Windows 命名内核对象),故拆成带 build tag 的两个文件;
// 共享段**布局**与 RPC 逻辑完全平台无关,全在 proc_main 里。
//
// 这正是三套独立 ABI 实现收敛为单一 RPC 实现的效果:
// 平台差异从「整套 stage 下发/写回逻辑各写一份」缩到「三个挂载函数」。
var procRuntimeFiles = []struct {
tmpl string // 内嵌模板路径
out string // 生成到插件目录的文件名
}{
{"templates/proc_main.go.tmpl", "z_proc_gen.go"},
{"templates/proc_shm_unix.go.tmpl", "z_proc_shm_unix.go"},
{"templates/proc_shm_windows.go.tmpl", "z_proc_shm_windows.go"},
}
// procEntryFile 是子进程插件的入口二进制名(与内核 internal/plugin/dynamic.go 的 binEntry 一致)。
//
// 全平台同名:进程边界本身就是 ABI 边界,不存在平台特有的动态库扩展名
// (对比 C ABI 时代的 .so/.dylib/.dll 三套产物 + 三套 ABI 实现)。
const procEntryFile = "plugin.bin"
// luaEntryFile 是 Lua 插件的入口。Lua 走解释器,不经过 Go 编译。
const luaEntryFile = "main.lua"
// procGenFile 是生成的主运行时文件名(兼容旧注释引用)。
// 前缀 z_ 使其在目录列表中排在业务代码之后。
const procGenFile = "z_proc_gen.go"
// generateProcRuntime 把子进程运行时(平台无关主体 + 两个平台挂载实现)
// 写入插件目录,返回清理函数。
func generateProcRuntime() (func(), error) {
// 清理历史 C ABI 产物:旧版 hmapdev原名 plugindev生成过这两个文件残留下来会与
// 本模板的 main 冲突。无需人工清理就能从旧版升级。
for _, stale := range []string{"z_bridge_gen.go", "z_entry.c"} {
os.Remove(stale)
}
var written []string
cleanup := func() {
for _, f := range written {
os.Remove(f)
}
}
for _, rf := range procRuntimeFiles {
data, err := procTemplates.ReadFile(rf.tmpl)
if err != nil {
cleanup()
return nil, fmt.Errorf("读取内嵌模板 %s: %w", rf.tmpl, err)
}
if err := os.WriteFile(rf.out, data, 0644); err != nil {
cleanup()
return nil, fmt.Errorf("写入 %s: %w", rf.out, err)
}
written = append(written, rf.out)
}
return cleanup, nil
}
// isProcEntry 已删除Go 插件一律产出 plugin.bin不再看 plg.json 的 entry 值。
//
// 为何忽略 entry17 个存量插件的 plg.json 都写着 "plugin.so"。若把 entry 当作
// 通道开关,迁移就得改 17 个文件——而「外部插件零改动」是本次迁移的硬约束。
// entry 现在只用于区分 Luamain.lua与 Go 插件。

View File

@ -0,0 +1,462 @@
package main
import (
"go/parser"
"go/token"
"os"
"regexp"
"strings"
"testing"
)
// 子进程运行时模板的静态检查Part 3
//
// 为什么需要这些测试:模板是插件的运行时半身,它与内核 internal/plugin/proc/
// 的协议名、共享段布局、字段索引必须逐一对齐。任一处漂移都会导致
// 「插件编译通过但运行时读错字段」——比编译错误难查得多。
//
// 模板改为真实 .go 源文件(而非 raw string的直接收益就是这类检查可行。
func loadProcTemplate(t *testing.T) string {
t.Helper()
data, err := procTemplates.ReadFile("templates/proc_main.go.tmpl")
if err != nil {
t.Fatalf("读取内嵌模板: %v", err)
}
return string(data)
}
// stripComments 去掉源码中的注释(用空白填充以保持偏移),只留可执行代码。
func stripComments(t *testing.T, src string) string {
t.Helper()
fs := token.NewFileSet()
f, err := parser.ParseFile(fs, "proc_main.go", src, parser.ParseComments)
if err != nil {
t.Fatalf("解析模板: %v", err)
}
out := []byte(src)
for _, cg := range f.Comments {
s := fs.Position(cg.Pos()).Offset
e := fs.Position(cg.End()).Offset
for i := s; i < e && i < len(out); i++ {
if out[i] != '\n' {
out[i] = ' '
}
}
}
return string(out)
}
// 模板必须是合法 Go 源码。
func TestProcTemplate_ParsesAsGo(t *testing.T) {
src := loadProcTemplate(t)
fs := token.NewFileSet()
if _, err := parser.ParseFile(fs, "proc_main.go", src, parser.AllErrors); err != nil {
t.Fatalf("模板不是合法 Go 源码: %v", err)
}
}
// 模板必须提供 main(),且不得含 cgo 痕迹。
//
// 零 cgo 是迁移的核心收益之一§3.7 锁仲裁回内核后整个架构无 cgo
// 一旦有人往模板里加 import "C",交叉编译立刻退回需要目标平台 C 工具链。
func TestProcTemplate_HasMainAndNoCgo(t *testing.T) {
src := loadProcTemplate(t)
if !strings.Contains(src, "func main()") {
t.Error("子进程模板必须有 main() 入口")
}
// 只检查代码,不检查注释——模板顶部的说明文字本身就提到了 C.CString/C.free
code := stripComments(t, src)
for _, forbidden := range []string{
`import "C"`,
"//export ",
"C.CString",
"C.GoString",
"C.free",
} {
if strings.Contains(code, forbidden) {
t.Errorf("模板不应含 cgo 痕迹 %q零 cgo 是迁移的核心收益)", forbidden)
}
}
}
// 模板引用的 method 名必须与内核 internal/plugin/proc/protocol.go 一致。
//
// 这里硬编码一份清单做对照:内核侧改了 method 名而模板没跟上时,
// 表现是插件调用返回「未知 method」测试能提前拦住。
func TestProcTemplate_CoversAllCoreMethods(t *testing.T) {
src := loadProcTemplate(t)
// 51 个 C ABI method id 平移后的名字§3.2),加 stage 锁仲裁 2 个
required := []string{
// 注册面
"tool.register", "stage.register", "output.register", "api.register", "input.register",
// IO 注入io.injectTextNoMem 见下方 deprecated内核保留为兼容旧二进制
// 当前模板改走 io.injectText + NoMemory 标志位,不再发那个 id
"io.injectText", "io.injectInterrupt", "io.injectInputSync",
"io.setToolBlocks",
// 多模态注入1.1.0 新增)。漏接线的后果是插件调 InjectInputMedia 静默无效果:
// 模板不发这个 RPC内核也就永远收不到而两边都不报错。
"io.injectMedia", "io.injectMediaSync", "io.injectInterruptMedia",
// 生命周期
"lifecycle.autoRestart",
// 图记忆
"memory.recall", "memory.commit", "memory.introspect", "memory.merge", "memory.purge",
// 文档记忆
"doc.query", "doc.insert", "doc.remove", "doc.stats",
// 文档媒体1.1.0 新增)
"doc.insertWithMedia",
// 知识库
"knowledge.search", "knowledge.add", "knowledge.list",
// 文本记忆
"textmemory.append",
// 设置
"settings.get", "settings.set", "settings.registerDef",
"settings.getCore", "settings.setCore", "settings.listCore",
"settings.getPlugin", "settings.setPlugin", "settings.listPlugin",
"settings.list", "settings.defs", "settings.dump", "settings.plugins",
"settings.dataDir",
// LLM
"llm.listSources", "llm.setSource", "llm.currentSource",
// 社交图
"social.getPerson", "social.getNetwork", "social.getTrait",
"social.getRelations", "social.listPersons",
// 插件管理
"plugin.reloadOne", "plugin.listLoaded", "plugin.isDisabled",
// 共享段锁仲裁新增C ABI 下不存在此概念)
"stage.lock", "stage.unlock",
}
for _, m := range required {
if !strings.Contains(src, `"`+m+`"`) {
t.Errorf("模板缺少 core method %q内核已提供插件侧未接线", m)
}
}
// 内核保留、但**当前模板不再发送**的 method id。
//
// 它们不是「公开接口新增却忘记接线」,而是刻意的向后兼容面:
// 内核必须继续接受用旧模板编出的插件二进制发来的 id而当前模板没有理由再发。
//
// 可复查的判据ba49dfd 之前的模板里 InjectTextNoMemory 发的就是
// "io.injectTextNoMem";注入标志位落地后它改走 "io.injectText" + NoMemory。
//
// 为何要单独列而不是直接从 required 删掉:这条守卫的价值在于「新接口必须接线」,
// 而把「内核有、模板就必须发」当不变量,会让它常驻误报——常驻误报的守卫迟早
// 被人习惯性忽略,那时真漏接线也就没人看见了。
deprecated := map[string]string{
"io.injectTextNoMem": "旧模板经此表达「不进记忆」;现由 io.injectText + NoMemory 表达",
}
// 反向保护allowlist 条目一旦又出现在模板里,说明它已过期,必须删掉,
// 否则这里会悄悄变成「永久豁免」的垃圾抽屉。
for m, why := range deprecated {
if strings.Contains(src, `"`+m+`"`) {
t.Errorf("deprecated 里的 %q 又出现在模板里(%s——条目已过期请从 deprecated 移除", m, why)
}
}
}
// 模板必须处理内核发来的全部调用(含无法 JSON 序列化的 Cleaner 回调)。
func TestProcTemplate_HandlesAllKernelCalls(t *testing.T) {
src := loadProcTemplate(t)
for _, m := range []string{
"handshake",
"plugin.init", "plugin.start", "plugin.stop",
"tool.invoke", "cleaner.invoke", "stage.invoke", "output.invoke",
} {
if !strings.Contains(src, `case "`+m+`"`) {
t.Errorf("模板未处理内核调用 %q", m)
}
}
}
// 工具调用的 payload 必须走内核标定的**调用帧**funccall 模型)。
//
// 共享内存是内核内部实现(插件作者只看到普通 map但模板必须在传输层
// 正确读写 frame / args_len / result_ref。漏接线的后果很隐蔽参数被静默
// 丢弃、结果只走内联,性能退化而不报错。
func TestProcTemplate_ToolInvokeUsesSharedRef(t *testing.T) {
src := loadProcTemplate(t)
for _, want := range []string{"frame", "args_len", "result_ref"} {
if !strings.Contains(src, want) {
t.Errorf("模板的 tool.invoke 必须处理 %qpayload 走内核标定的调用帧)", want)
}
}
}
// 模板必须通过 arena.alloc / arena.free 向内核申请与归还共享内存。
//
// 共享内存是内核独占管理的**内部实现**:插件不能自己维护分配游标。
// 历史上两版跨进程分配器bump 游标 / 模板内位图 CAS都因为把可变
// 分配状态放在共享内存里而出竞态,所以这里做回归保护。
func TestProcTemplate_UsesKernelArenaRPC(t *testing.T) {
src := loadProcTemplate(t)
for _, m := range []string{`"arena.alloc"`, `"arena.free"`} {
if !strings.Contains(src, m) {
t.Errorf("模板缺少内核共享内存 RPC %s插件必须向内核申请/归还)", m)
}
}
// 禁止插件侧再出现本地分配器符号。
//
// 只查代码不查注释:注释里会解释“为什么不再这么做”。
code := stripComments(t, src)
for _, forbidden := range []string{"arenaUsed", "arenaWrite"} {
if strings.Contains(code, forbidden) {
t.Errorf("模板不应再出现插件侧分配器 %q共享内存由内核独占管理", forbidden)
}
}
}
// 共享段布局常量必须与内核 internal/plugin/proc/shm.go 一致。
//
// 字段索引错位是最危险的漂移:插件会读到相邻字段的数据,
// 而两边都不报错(同为 []byte
func TestProcTemplate_ShmLayoutMatchesKernel(t *testing.T) {
src := loadProcTemplate(t)
// 与内核 shm.go 的 offXxx 常量对齐(值比较,不依赖 gofmt 的对齐空白)
layout := map[string]string{
"shmOffMagic": "0",
"shmOffVersion": "4",
"shmOffArenaBase": "8",
"shmOffArenaCap": "12",
"shmOffArenaUsed": "16",
"shmOffCtxBase": "20",
"shmOffSeq": "24",
// 与内核 stageFieldCount / sliceSize 对齐
"shmStageFieldCount": "18",
"shmSliceSize": "8",
"shmVersion": "1",
}
constRe := func(name, want string) bool {
// gofmt 会对齐常量块,故容许 name 与 = 之间有任意空白
re := regexp.MustCompile(`\b` + regexp.QuoteMeta(name) + `\s*=\s*` + regexp.QuoteMeta(want) + `\b`)
return re.MatchString(src)
}
for name, want := range layout {
if !constRe(name, want) {
t.Errorf("共享段常量 %s 应为 %s须与内核 internal/plugin/proc/shm.go 一致)", name, want)
}
}
// 字段枚举顺序:内核 stageField 的前若干项
fieldOrder := []string{
"fRawMessage = iota", "fUserID", "fGroupID", "fLLMText",
"fReasoningContent", "fFinalText", "fResponse", "fPhase",
"fContextMsgs", "fToolCalls", "fToolResults", "fMemory",
"fTokenUsage", "fErrors",
"fExtraMediaBlocks", "fExtraMediaType", "fExtraInputSource", "fExtraOutputChannel",
}
idx := -1
for _, f := range fieldOrder {
at := strings.Index(src, f)
if at < 0 {
t.Fatalf("模板缺少字段常量 %s", f)
}
if at <= idx {
t.Errorf("字段常量 %s 的声明顺序与内核 stageField 枚举不一致", f)
}
idx = at
}
}
// stage 处理必须「拿锁 → 读 → handler → 只写脏字段 → 放锁」。
//
// 只写脏字段是消除 lost update 的核心:只读插件零写入,
// 不可能覆盖其他插件的改写(对照 C ABI 副本模型实测 35.8~36.8% 丢失)。
func TestProcTemplate_StageFlowUsesLockAndDirtyWrite(t *testing.T) {
src := loadProcTemplate(t)
for _, want := range []string{
"func handleStageInvoke(",
"stage.lock",
"readStageContext()",
"takeStageSnapshot(",
"writeStageDirty(",
"stage.unlock",
} {
if !strings.Contains(src, want) {
t.Errorf("stage 处理链路缺少 %q", want)
}
}
// 顺序检查:加锁必须在读取之前,写回必须在解锁之前
iLock := strings.Index(src, `callCoreVoid("stage.lock"`)
iRead := strings.Index(src, "readStageContext()")
iWrite := strings.Index(src, "writeStageDirty(sc, snap)")
if iLock < 0 || iRead < 0 || iWrite < 0 {
t.Fatal("stage 链路关键调用缺失")
}
// readStageContext 的定义在前,调用在后;取 handleStageInvoke 内的位置
stageFn := src[strings.Index(src, "func handleStageInvoke("):]
iLockFn := strings.Index(stageFn, `callCoreVoid("stage.lock"`)
iReadFn := strings.Index(stageFn, "readStageContext()")
iWriteFn := strings.Index(stageFn, "writeStageDirty(sc, snap)")
if !(iLockFn < iReadFn && iReadFn < iWriteFn) {
t.Error("stage 链路顺序应为 加锁 → 读取 → 写回")
}
}
// 快照必须存序列化字符串而非 Go 值。
//
// ❗ 这是修 C ABI 侧 11.3 时踩过的坑StageContext 的切片字段与读出的值
// 共享底层内容handler 原地改元素sc.ToolResults[0].Result = x
// 直接持有 Go 值的快照会跟着变,脏字段计算失效、修复静默失效。
func TestProcTemplate_SnapshotStoresSerializedStrings(t *testing.T) {
src := loadProcTemplate(t)
if !strings.Contains(src, "strs map[int]string") ||
!strings.Contains(src, "jsons map[int]string") {
t.Error("stageSnapshot 必须存序列化字符串(切片共享底层数组,存 Go 值会让脏字段计算失效)")
}
if !strings.Contains(src, "json.Marshal(v)") {
t.Error("takeStageSnapshot 应对容器字段做 json.Marshal")
}
}
// arena 用尽必须显式报错不得静默截断§4.4 风险登记)。
func TestProcTemplate_ArenaExhaustionErrors(t *testing.T) {
src := loadProcTemplate(t)
if !strings.Contains(src, "arena 空间不足") {
t.Error("shmWrite 在 arena 不足时必须报错,不得静默截断")
}
}
// 日志必须走 stderrstdout 是 RPC 通道,写日志会破坏 NDJSON 帧。
func TestProcTemplate_LogsToStderr(t *testing.T) {
src := loadProcTemplate(t)
if !strings.Contains(src, "log.SetOutput(os.Stderr)") {
t.Error("日志必须走 stderr否则会破坏 stdout 的 RPC 帧")
}
}
// 请求必须在独立 goroutine 里处理。
//
// handler 内会反向调用内核并等应答;若在读循环里同步处理,
// 就没人读应答帧 → 死锁。
func TestProcTemplate_DispatchesRequestsConcurrently(t *testing.T) {
src := loadProcTemplate(t)
if !strings.Contains(src, "go handleKernelRequest(&req)") {
t.Error("请求须在独立 goroutine 处理handler 内反向调用内核,同步处理会死锁)")
}
}
// 协议与共享内存区域版本/魔数不匹配必须拒绝,不得半兼容运行。
func TestProcTemplate_RejectsVersionMismatch(t *testing.T) {
src := loadProcTemplate(t)
// §13.1 起共享段合并为单一「统一区域」,魔数校验文案随之更新。
for _, want := range []string{"协议版本不匹配", "共享段版本不匹配", "统一区域魔数不匹配"} {
if !strings.Contains(src, want) {
t.Errorf("握手应校验并拒绝 %q", want)
}
}
}
// 全平台统一产出 plugin.bin。
//
// 这是三套独立 ABI 实现(.so/.dylib/.dll收敛为单一 RPC 实现的直接后果:
// 进程边界本身就是 ABI 边界,不存在平台特有的动态库扩展名。
// §9.2 记录的「Windows DLL 路径只下发 3 字段、无写回」随之消失——
// Windows 走的是与 Linux 完全相同的 RPC 实现。
func TestResolveBuild_AllPlatformsProduceBin(t *testing.T) {
for _, target := range []string{
"linux/amd64", "linux/arm64",
"darwin/amd64", "darwin/arm64",
"windows/amd64",
"freebsd/amd64",
} {
cfg, errMsg := resolveBuild(target)
if cfg == nil {
t.Fatalf("resolveBuild(%q) 失败: %s", target, errMsg)
}
if cfg.entryFile != procEntryFile {
t.Errorf("%s: 产物应为 %s实际 %s", target, procEntryFile, cfg.entryFile)
}
}
}
// lua 目标仍走解释器路径entry 字段唯一仍在使用的用途)。
func TestResolveBuild_LuaIsSeparatePath(t *testing.T) {
for _, target := range []string{"lua", ""} {
cfg, kind := resolveBuild(target)
if cfg != nil {
t.Errorf("%q 应返回 nil cfgLua 不经 Go 编译)", target)
}
if kind != "lua" {
t.Errorf("%q 应识别为 lua实际 %q", target, kind)
}
}
}
// 不支持的平台明确报错,不静默产出错误产物。
func TestResolveBuild_UnsupportedOSErrors(t *testing.T) {
cfg, errMsg := resolveBuild("plan9/amd64")
if cfg != nil {
t.Error("不支持的平台应返回 nil cfg")
}
if !strings.Contains(errMsg, "unsupported") {
t.Errorf("应给出 unsupported 提示,实际 %q", errMsg)
}
}
// bundle 产物在 zip 内按平台加后缀(全平台同名 plugin.bin 会相互覆盖)。
func TestBundleTargets_HavePlatformSuffixedEntries(t *testing.T) {
seen := map[string]bool{}
for _, bt := range allBundleTargets {
if seen[bt.entry] {
t.Errorf("zip 条目名重复: %s会相互覆盖", bt.entry)
}
seen[bt.entry] = true
if !strings.HasPrefix(bt.entry, procEntryFile+".") {
t.Errorf("bundle 条目 %q 应以 %s. 为前缀", bt.entry, procEntryFile)
}
}
if len(allBundleTargets) == 0 {
t.Error("bundle 目标表不应为空")
}
}
// C ABI 工具链残留必须彻底清除:不得再有 .so/.dylib/.dll 产物路径,
// 也不得再引用 c-shared 构建模式或 MinGW 探测。
func TestToolchain_NoCABIResiduals(t *testing.T) {
for _, f := range []string{"cmd_build.go", "templates.go", "cmd_init.go", "proc_runtime.go"} {
data, err := os.ReadFile(f)
if err != nil {
t.Fatalf("读 %s: %v", f, err)
}
src := stripComments(t, string(data))
for _, forbidden := range []string{
"c-shared",
"CGO_ENABLED=1",
"detectWindowsCC",
"generateBridge",
"tmplLinuxBridge",
"tmplPluginInitC",
} {
if strings.Contains(src, forbidden) {
t.Errorf("%s 仍含 C ABI 残留 %q", f, forbidden)
}
}
}
}
// Go 插件的构建不再读 plg.json 的 entry 值。
//
// 这是「外部插件零改动」的关键17 个存量插件的 plg.json 都写着 "plugin.so"
// 若把 entry 当通道开关,迁移就得改 17 个文件。
func TestToolchain_IgnoresEntryForGoPlugins(t *testing.T) {
data, err := os.ReadFile("cmd_build.go")
if err != nil {
t.Fatalf("读 cmd_build.go: %v", err)
}
src := stripComments(t, string(data))
if strings.Contains(src, "isProcEntry") {
t.Error("isProcEntry 应已删除——Go 插件一律产出 plugin.bin不看 entry 值")
}
// entry 仅剩 Lua 判定这一处用途
if !strings.Contains(src, "luaEntryFile") {
t.Error("IsLua 应改用 luaEntryFile 常量")
}
}

77
tools/hmapdev/replace.go Normal file
View File

@ -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), "\r\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
}

View File

@ -0,0 +1,247 @@
package main
import (
"encoding/json"
"testing"
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// 本测试验证 tmplLinuxBridge 中 snapshotWritable + changedFieldsOnly 的语义plan.md 11.3)。
// 模板字符串本身无法直接单测,这里以同一份逻辑复刻,防止回归。
// ❗ 模板与本文件须同步修改。
//
// 关键陷阱第一版实现踩过stageContextWritable 返回的切片字段与 sc 共享底层数组,
// handler 原地改元素时"before 快照"会跟着变diff 看不到变更 → 修复静默失效。
// 故 before 必须是**序列化后的字符串快照**。
func writable(sc *sdk.StageContext) map[string]interface{} {
m := map[string]interface{}{
"raw_message": sc.RawMessage,
"user_id": sc.UserID,
"group_id": sc.GroupID,
"phase": string(sc.Phase),
"llm_text": sc.LLMText,
"final_text": sc.FinalText,
"no_memory": sc.NoMemory,
}
if sc.Response != nil {
m["response"] = *sc.Response
}
if len(sc.ToolCalls) > 0 {
m["tool_calls"] = sc.ToolCalls
}
if len(sc.ToolResults) > 0 {
m["tool_results"] = sc.ToolResults
}
return m
}
// snapshot 对应模板里的 snapshotWritable逐字段序列化为不可变快照。
func snapshot(sc *sdk.StageContext) map[string]string {
snap := map[string]string{}
for k, v := range writable(sc) {
b, err := json.Marshal(v)
if err != nil {
continue
}
snap[k] = string(b)
}
return snap
}
// diffOnly 对应模板里的 changedFieldsOnly。
func diffOnly(before map[string]string, after map[string]interface{}) map[string]interface{} {
diff := map[string]interface{}{}
keys := map[string]bool{}
for k := range before {
keys[k] = true
}
for k := range after {
keys[k] = true
}
for k := range keys {
bRaw, bHas := before[k]
a, aHas := after[k]
switch {
case aHas && !bHas:
diff[k] = a
case aHas && bHas:
ab, _ := json.Marshal(a)
if bRaw != string(ab) {
diff[k] = a
}
case bHas && !aHas:
switch k {
case "tool_calls":
diff[k] = []sdk.ToolCall{}
case "tool_results":
diff[k] = []sdk.ToolResult{}
}
}
}
return diff
}
// 只读插件(如 weather 的 AfterToolcall不改任何字段 → 零回传。
// 这是修复 lost update 的关键:旧实现会回传它收到的旧快照,覆盖 sanitizer 的清洗结果。
func TestChangedFieldsOnly_ReadOnlyPluginReturnsNothing(t *testing.T) {
sc := &sdk.StageContext{
RawMessage: "hello",
LLMText: "world",
ToolResults: []sdk.ToolResult{
{CallID: "c1", Name: "weather_query", Success: true, Result: "已清洗结果"},
},
}
before := snapshot(sc)
// 只读 handler读了但没改
_ = sc.ToolResults[0].Result
diff := diffOnly(before, writable(sc))
if len(diff) != 0 {
t.Fatalf("只读插件应零回传,实际回传 %d 个字段: %v", len(diff), diff)
}
}
// 改写插件(如 sanitizer 改 ToolResults→ 只回传被改的字段。
// ⚠️ 这里是原地改切片元素,正是共享底层数组陷阱的触发场景。
func TestChangedFieldsOnly_WriterReturnsOnlyChanged(t *testing.T) {
sc := &sdk.StageContext{
RawMessage: "hello",
LLMText: "world",
ToolResults: []sdk.ToolResult{
{CallID: "c1", Name: "weather_query", Success: true, Result: "带\x1b[31mANSI\x1b[0m脏数据"},
},
}
before := snapshot(sc)
// sanitizer handler原地清洗 ToolResults
sc.ToolResults[0].Result = "带ANSI脏数据"
diff := diffOnly(before, writable(sc))
if len(diff) != 1 {
t.Fatalf("应只回传 tool_results 一个字段,实际 %d 个: %v", len(diff), diff)
}
if _, ok := diff["tool_results"]; !ok {
t.Fatalf("回传字段应为 tool_results实际 %v", diff)
}
// raw_message / llm_text 未改,不应出现(否则会覆盖其他插件的改写)
if _, ok := diff["raw_message"]; ok {
t.Error("raw_message 未改却被回传(会覆盖其他插件的改写)")
}
if _, ok := diff["llm_text"]; ok {
t.Error("llm_text 未改却被回传")
}
}
// 改写标量字段(如 before_output 改 FinalText→ 只回传该字段。
func TestChangedFieldsOnly_ScalarChange(t *testing.T) {
sc := &sdk.StageContext{
RawMessage: "hi",
FinalText: " 带空白的回复 ",
LLMText: "原始",
}
before := snapshot(sc)
sc.FinalText = "带空白的回复"
diff := diffOnly(before, writable(sc))
if len(diff) != 1 || diff["final_text"] != "带空白的回复" {
t.Fatalf("应只回传 final_text实际 %v", diff)
}
}
// 首次设置 response短路→ 回传。
func TestChangedFieldsOnly_NewResponseIsReturned(t *testing.T) {
sc := &sdk.StageContext{RawMessage: "hi"}
before := snapshot(sc)
resp := "被插件短路"
sc.Response = &resp
diff := diffOnly(before, writable(sc))
if v, ok := diff["response"]; !ok || v != "被插件短路" {
t.Fatalf("新设置的 response 应回传,实际 %v", diff)
}
}
// 清空切片字段 → 显式回传空值让内核跟随。
func TestChangedFieldsOnly_ClearedSliceIsReturnedAsEmpty(t *testing.T) {
sc := &sdk.StageContext{
ToolCalls: []sdk.ToolCall{{ID: "t1", Name: "cmd_run"}},
}
before := snapshot(sc)
sc.ToolCalls = nil // 插件拒绝了全部工具调用
diff := diffOnly(before, writable(sc))
v, ok := diff["tool_calls"]
if !ok {
t.Fatalf("清空 tool_calls 应显式回传空值,实际 %v", diff)
}
if arr, _ := v.([]sdk.ToolCall); len(arr) != 0 {
t.Fatalf("应回传空切片,实际 %v", v)
}
}
// 复刻现网场景(实验 13sanitizer 清洗后 weather 只读回传,清洗结果不得被覆盖。
// 旧实现下 weather 会回传自己收到的旧快照(含脏数据),覆盖 sanitizer 的清洗(丢失率 1.6~4.3%)。
func TestChangedFieldsOnly_ProductionScenarioNoOverwrite(t *testing.T) {
dirty := "天气:晴 \x1b[31m28°C\x1b[0m"
clean := "天气:晴 28°C"
// 内核下发的原始快照(两插件各拿到一份副本)
kernelSnapshot := map[string]interface{}{
"raw_message": "查天气",
"llm_text": "",
"final_text": "",
"user_id": "u1",
"group_id": "",
"phase": "after_toolcall",
"no_memory": false,
"tool_results": []sdk.ToolResult{{CallID: "c1", Name: "weather_query", Result: dirty}},
}
// sanitizer 副本:清洗
scSan := &sdk.StageContext{
RawMessage: "查天气",
UserID: "u1",
Phase: sdk.StageAfterToolcall,
ToolResults: []sdk.ToolResult{{CallID: "c1", Name: "weather_query", Result: dirty}},
}
beforeSan := snapshot(scSan)
scSan.ToolResults[0].Result = clean
diffSan := diffOnly(beforeSan, writable(scSan))
// weather 副本:只读,不改
scWea := &sdk.StageContext{
RawMessage: "查天气",
UserID: "u1",
Phase: sdk.StageAfterToolcall,
ToolResults: []sdk.ToolResult{{CallID: "c1", Name: "weather_query", Result: dirty}},
}
beforeWea := snapshot(scWea)
diffWea := diffOnly(beforeWea, writable(scWea))
// weather 必须零回传,否则它的旧快照会覆盖 sanitizer 的清洗
if len(diffWea) != 0 {
t.Fatalf("weather 只读却回传 %v —— 会覆盖 sanitizer 清洗结果", diffWea)
}
// sanitizer 必须回传 tool_results
if _, ok := diffSan["tool_results"]; !ok {
t.Fatalf("sanitizer 改写了 tool_results 却未回传:%v", diffSan)
}
// 内核按 sanitizer → weather 顺序应用 diffweather 后到,是最坏情形)
kernel := map[string]interface{}{}
for k, v := range kernelSnapshot {
kernel[k] = v
}
for k, v := range diffSan {
kernel[k] = v
}
for k, v := range diffWea {
kernel[k] = v
}
res, _ := kernel["tool_results"].([]sdk.ToolResult)
if len(res) == 0 || res[0].Result != clean {
t.Fatalf("清洗结果被覆盖:期望 %q实际 %v", clean, kernel["tool_results"])
}
}

520
tools/hmapdev/templates.go Normal file
View File

@ -0,0 +1,520 @@
package main
// tmplPlgJSON is the plg.json template
const tmplPlgJSON = `{
"name": "{{.Plg.Name}}",
"name_zh": "{{.Plg.NameZh}}",
"name_en": "{{.Plg.NameEn}}",
"version": "{{.Plg.Version}}",
"description": "{{.Plg.Description}}",
"author": "{{.Plg.Author}}",
"entry": "{{.Plg.Entry}}",
"tags": [{{range $i, $t := .Plg.Tags}}{{if $i}}, {{end}}"{{$t}}"{{end}}],
"targets": "{{.Plg.Targets}}"
}
`
const tmplGoMod = `module {{.ModulePath}}
go {{.GoVersion}}
require {{.SDKModule}} {{.SDKVersion}}
{{if .SDKLocalPath}}
// SDK 指向本机源码。gitcode 的模块不在 proxy.golang.org 上,
// 没有这条 replace 就需要 go.sum 条目,而那个条目无处可拉。
// 若你已有可访问的私有 proxy可删掉本行。
replace {{.SDKModule}} => {{.SDKLocalPath}}
{{end}}`
const tmplPluginGo = `package main
import (
"fmt"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
type Plugin struct {
name string
sdk *sdk.PluginSDK
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s
s.RegisterStopHandler(func() { fmt.Printf("[%s] stop handler running\n", p.name) })
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "plugin.{{.Plg.Name}}.example", Default: "hello", Type: "string",
DisplayName: "示例配置", Description: "An example configuration key",
Category: "{{.Plg.Name}}",
})
tp := p.name + "_"
s.RegisterTool(tp+"hello", sdk.ToolDef{
Name: tp + "hello",
Description: "A hello world tool",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
NoMemory: false, // 工具输出对 LLM 注意力有信号价值时为 false纯操作工具为 true
// Cleaner: func(output string) string {
// // 工具输出参与向量化/jieba/蒸馏前,在此过滤噪音
// return output
// },
}, p.handleHello)
fmt.Printf("[%s] started\n", p.name)
return nil
}
func (p *Plugin) Stop() error { fmt.Printf("[%s] stopped\n", p.name); return nil }
func (p *Plugin) handleHello(args map[string]interface{}) (interface{}, error) {
return map[string]interface{}{"content": "Hello from {{.Plg.Name}} plugin!"}, nil
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}
`
const tmplSDKLua = `-- HomeAgent Lua Plugin SDK (standalone mock)
sdk = {}
function sdk.log(level, msg) print("[lua-plugin] " .. tostring(level) .. ": " .. tostring(msg)) end
function sdk.register_tool(name, def, handler) print("[lua-plugin] register_tool: " .. tostring(name)) end
function sdk.register_stage(stage, handler, scope) print("[lua-plugin] register_stage: " .. tostring(stage) .. " scope=" .. tostring(scope)) end
function sdk.register_api(name) print("[lua-plugin] register_api: " .. tostring(name)) end
function sdk.register_output_channel(name, caps, desc, def, handler) print("[lua-plugin] register_output_channel: " .. tostring(name)) end
function sdk.register_input_channel(name, def) print("[lua-plugin] register_input_channel: " .. tostring(name)) end
function sdk.get_setting(key) return nil end
function sdk.set_setting(key, value) print("[lua-plugin] set_setting: " .. tostring(key)) end
function sdk.inject_text(source, channel, text) print("[lua-plugin] inject_text: " .. tostring(source)) end
function sdk.inject_interrupt(source, channel, text) print("[lua-plugin] inject_interrupt: " .. tostring(source)) end
function sdk.inject_text_no_memory(source, channel, text) print("[lua-plugin] inject_text_no_memory: " .. tostring(source)) end
function sdk.set_auto_restart(enabled) print("[lua-plugin] set_auto_restart: " .. tostring(enabled)) end
sdk.memory = {}
function sdk.memory.recall(query, depth) return {entities={}, relations={}} end
function sdk.memory.commit(triples) return nil end
function sdk.memory.introspect() return {} end
function sdk.memory.merge(source, target) return 0 end
function sdk.memory.purge(criteria, hard) return 0 end
sdk.doc = {}
function sdk.doc.query(text, top_k) return {} end
function sdk.doc.insert(doc) return nil end
function sdk.doc.remove(id) return nil end
function sdk.doc.stats() return {} end
sdk.knowledge = {}
function sdk.knowledge.search(query, limit) return {} end
function sdk.knowledge.add(tag, content) return nil end
function sdk.knowledge.list() return {} end
sdk.text_memory = {}
function sdk.text_memory.append(evt) return nil end
sdk.llm = {}
function sdk.llm.list_sources() return {} end
function sdk.llm.set_source(name) return nil end
function sdk.llm.current_source() return nil end
sdk.social = {}
function sdk.social.get_person(name) return {} end
function sdk.social.get_network(name, depth) return {} end
function sdk.social.get_trait(name, trait) return {value=nil, found=false} end
function sdk.social.get_relations(name) return {} end
function sdk.social.list_persons() return {} end
sdk.settings = {}
function sdk.settings.get_core(key) return nil end
function sdk.settings.set_core(key, value) return nil end
function sdk.settings.list_core(prefix) return {} end
function sdk.settings.get_plugin(plugin, key) return nil end
function sdk.settings.set_plugin(plugin, key, value) return nil end
function sdk.settings.list_plugin(plugin, prefix) return {} end
function sdk.settings.list(prefix) return {} end
function sdk.settings.register_def(def) return nil end
function sdk.settings.defs(prefix) return {} end
function sdk.settings.dump() return {} end
function sdk.settings.plugins() return {} end
sdk.json = {}
function sdk.json.encode(val)
if type(val) == "string" then return '"' .. val:gsub('"', '\\"'):gsub('\n', '\\n') .. '"'
elseif type(val) == "number" or type(val) == "boolean" then return tostring(val)
elseif type(val) == "table" then local parts, i = {}, 1
for k, v in pairs(val) do parts[i] = sdk.json.encode(k) .. ":" .. sdk.json.encode(v); i = i + 1 end
return "{" .. table.concat(parts, ",") .. "}" end
return "null"
end
function sdk.json.decode(str) local ok, fn = pcall(load, "return " .. str); if ok then return fn() end; return nil end
sdk.http = {}
function sdk.http.get(url) print("[lua-plugin] http.get: " .. tostring(url)); return {status=200, body='{"mock":true}', headers={}} end
function sdk.http.post(url, body, ct) print("[lua-plugin] http.post: " .. tostring(url)); return {status=200, body='{"mock":true}', headers={}} end
return sdk
`
const tmplMainLua = `-- {{.Plg.Name}} plugin
local plugin = { name = "{{.Plg.Name}}" }
function plugin.start(sdk)
sdk.log("info", "{{.Plg.Name}} starting...")
sdk.register_tool("{{.Plg.Name}}_hello", {
description = "A hello world tool",
parameters = { type = "object", properties = {} }
}, function(args) return { content = "Hello from {{.Plg.Name}} plugin!" } end)
sdk.log("info", "{{.Plg.Name}} started")
end
function plugin.stop() sdk.log("info", "{{.Plg.Name}} stopped") end
return plugin
`
// ============================================================
// Remote Device Adapter Templates
// ============================================================
const tmplRemoteDeviceMain = `#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include "ha_remotedevice.h"
/* ============================================================
* {{.Plg.Name}} — Remote Device Adapter
*
* 声明式远程设备接入示例。
* 用户只需实现:
* 1. ha_transport_t 的 4 个函数
* 2. 声明 handlers 表(设备支持哪些命令 + 对应的处理函数)
* 其余协议细节WS 握手、hello/bind、心跳、重连、命令分发、结果回执由 SDK 自动处理。
* ============================================================ */
/* ====================== 传输层实现 ======================
*
* 请为你的平台实现以下 4 个函数:
* connect(ctx, host, port) — 建立 TCP 连接
* send(ctx, data, len) — 发送数据
* recv(ctx, buf, len) — 接收数据(阻塞,返回实际接收字节数)
* close(ctx) — 关闭连接
*
* 示例POSIX socket 实现
*/
#if defined(_WIN32) || defined(_WIN64)
/* Windows 平台需包含 winsock2.h */
#error "Please implement transport for your platform (see example below)"
#else
/* POSIX (Linux, macOS, ESP-IDF, Zephyr, etc.) */
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <unistd.h>
struct transport_ctx {
int sock;
};
static int transport_connect(void *ctx, const char *host, uint16_t port) {
struct transport_ctx *tc = (struct transport_ctx *)ctx;
struct hostent *he = gethostbyname(host);
if (!he) return -1;
tc->sock = socket(AF_INET, SOCK_STREAM, 0);
if (tc->sock < 0) return -1;
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
memcpy(&addr.sin_addr, he->h_addr_list[0], he->h_length);
if (connect(tc->sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
close(tc->sock);
tc->sock = -1;
return -1;
}
return 0;
}
static int transport_send(void *ctx, const uint8_t *data, int len) {
struct transport_ctx *tc = (struct transport_ctx *)ctx;
int sent = 0;
while (sent < len) {
int n = (int)send(tc->sock, data + sent, len - sent, 0);
if (n <= 0) return -1;
sent += n;
}
return sent;
}
static int transport_recv(void *ctx, uint8_t *buf, int len) {
struct transport_ctx *tc = (struct transport_ctx *)ctx;
int n = (int)recv(tc->sock, buf, len, 0);
return n;
}
static void transport_close(void *ctx) {
struct transport_ctx *tc = (struct transport_ctx *)ctx;
if (tc->sock >= 0) {
close(tc->sock);
tc->sock = -1;
}
}
#endif
/* ====================== 声明式命令处理 ======================
*
* 每个命令对应一个处理函数,通过填写 ha_cmd_result_t 返回数据。
* SDK 自动回执结果,无需手动调用 send_result。
*
* 返回方式:
* 1. 文本输出:填写 result->output
* 2. 二进制数据:设置 result->has_binary=1 并填写 binary_data/len/mime
* 3. 错误:设置 result->status=1 并填写 result->error
* 4. 返回 HA_OK 表示处理成功,其他值表示处理失败
*/
/* ESP32-CAM 摄像头处理 */
static ha_status_t handle_camerasue(const char *req_id, const char *args,
ha_cmd_result_t *result, void *userdata) {
(void)req_id; (void)userdata;
int duration = 0;
if (args && args[0]) duration = atoi(args);
printf("[camera] %s (duration=%ds)\n", duration ? "record" : "snapshot", duration);
/* 返回文本结果base64 图片) */
result->status = 0;
result->output = "data:image/jpeg;base64,/9j/4AAQ...";
return HA_OK;
}
/* 屏幕截图处理 */
static ha_status_t handle_screensee(const char *req_id, const char *args,
ha_cmd_result_t *result, void *userdata) {
(void)req_id; (void)args; (void)userdata;
printf("[screen] screenshot\n");
result->status = 0;
result->output = "data:image/png;base64,iVBORw0KGgo...";
return HA_OK;
}
/* 语音播报处理 */
static ha_status_t handle_speakeruse(const char *req_id, const char *args,
ha_cmd_result_t *result, void *userdata) {
(void)req_id; (void)userdata;
printf("[speaker] TTS: %s\n", args ? args : "");
result->status = 0;
result->output = "speakeruse done";
return HA_OK;
}
/* 远程操控处理computeruse */
static ha_status_t handle_computeruse(const char *req_id, const char *args,
ha_cmd_result_t *result, void *userdata) {
(void)req_id; (void)userdata;
const char *action = NULL;
const char *json_str = NULL;
ha_cmd_parse_json(args, &action, &json_str);
printf("[computeruse] action=%s\n", action ? action : "unknown");
result->status = 0;
result->output = "computeruse done";
return HA_OK;
}
/* 剪贴板读取 */
static ha_status_t handle_clipboardsee(const char *req_id, const char *args,
ha_cmd_result_t *result, void *userdata) {
(void)req_id; (void)args; (void)userdata;
result->status = 0;
result->output = "clipboard content";
return HA_OK;
}
/* 剪贴板写入 */
static ha_status_t handle_clipboardsue(const char *req_id, const char *args,
ha_cmd_result_t *result, void *userdata) {
(void)req_id; (void)userdata;
printf("[clipboard] write: %s\n", args ? args : "");
result->status = 0;
result->output = "clipboard written";
return HA_OK;
}
/* 屏幕显示 */
static ha_status_t handle_screensue(const char *req_id, const char *args,
ha_cmd_result_t *result, void *userdata) {
(void)req_id; (void)userdata;
printf("[screensue] show: %s\n", args ? args : "");
result->status = 0;
result->output = "screensue shown";
return HA_OK;
}
/* Shell 命令处理 */
static ha_status_t handle_shell(const char *req_id, const char *args,
ha_cmd_result_t *result, void *userdata) {
(void)req_id; (void)userdata;
printf("[shell] cmd: %s\n", args ? args : "");
result->status = 0;
result->output = "shell output";
return HA_OK;
}
/* 设备信息查询 */
static ha_status_t handle_deviceinfo(const char *req_id, const char *args,
ha_cmd_result_t *result, void *userdata) {
(void)req_id; (void)args; (void)userdata;
result->status = 0;
result->output = "{\"platform\":\"linux\",\"arch\":\"x86_64\"}";
return HA_OK;
}
/* ====================== 连接状态回调 ====================== */
static void on_state(int connected, void *userdata) {
(void)userdata;
printf("[devicelink] state: %s\n", connected ? "connected" : "disconnected");
}
/* ====================== 主函数 ====================== */
int main(int argc, char *argv[]) {
/* 传输层上下文 */
struct transport_ctx tctx;
tctx.sock = -1;
ha_transport_t transport = {
.connect = transport_connect,
.send = transport_send,
.recv = transport_recv,
.close = transport_close,
.ctx = &tctx,
};
/* ===== 声明式设备配置 ===== */
/* 声明设备能力 */
const char *caps[] = {
"status", "cmdrun", "deviceinfo",
"camerasue", "screensee", "speakeruse",
"computeruse", "clipboardsee", "clipboardsue",
"screensue",
NULL
};
/* 声明命令处理表:设备支持哪些命令,以及对应的处理函数 */
ha_cmd_handler_def_t handlers[] = {
{.command = "shell", .handler = handle_shell},
{.command = "camerasue", .handler = handle_camerasue},
{.command = "screensee", .handler = handle_screensee},
{.command = "speakeruse", .handler = handle_speakeruse},
{.command = "computeruse", .handler = handle_computeruse},
{.command = "clipboardsee", .handler = handle_clipboardsee},
{.command = "clipboardsue", .handler = handle_clipboardsue},
{.command = "screensue", .handler = handle_screensue},
{.command = "deviceinfo", .handler = handle_deviceinfo},
{.command = NULL}, /* 标记结束 */
};
ha_config_t config = {
.transport = transport,
.server = "127.0.0.1:9890",
.token = "your-token-here",
.device = {
.device_id = "{{.Plg.Name}}",
.name = "{{.Plg.NameEn}}",
.kind = "computer",
.caps = caps,
.info_json = "{\"platform\":\"linux\",\"arch\":\"x86_64\"}",
},
.handlers = handlers, /* 声明式命令处理表 */
.on_state = on_state,
.ping_interval = 30,
};
ha_client_t *client = ha_client_new(&config);
if (!client) {
fprintf(stderr, "Failed to create client\n");
return 1;
}
printf("Starting remote device adapter: {{.Plg.Name}}\n");
printf(" Server: %s\n", config.server);
printf(" Device ID: %s\n", config.device.device_id);
printf(" Kind: %s\n", config.device.kind);
printf(" Caps: ");
for (const char **p = caps; *p; p++) printf("%s ", *p);
printf("\n");
ha_status_t st = ha_client_start(client);
if (st != HA_OK) {
fprintf(stderr, "Failed to connect: %d\n", st);
ha_client_destroy(client);
return 1;
}
printf("Connected! Entering main loop...\n");
/* 主循环 */
while (1) {
ha_status_t st = ha_client_process(client);
if (st == HA_ERR_DISCONNECTED) {
printf("Disconnected, exiting.\n");
break;
}
#if defined(_WIN32) || defined(_WIN64)
Sleep(10);
#else
usleep(10000);
#endif
}
ha_client_stop(client);
ha_client_destroy(client);
return 0;
}
`
const tmplRemoteDeviceCMake = `cmake_minimum_required(VERSION 3.10)
project({{.Plg.Name}} VERSION 0.1.0 LANGUAGES C)
# ============================================================
# {{.Plg.Name}} — Remote Device Adapter
# ============================================================
# 设置 SDK 路径(默认使用内置 SDK也可通过 -DSDK_PATH=... 指定)
set(SDK_PATH "${CMAKE_CURRENT_SOURCE_DIR}/ha_remotedevice"
CACHE PATH "Path to ha_remotedevice SDK")
# 添加 SDK 子目录
if(EXISTS "${SDK_PATH}/CMakeLists.txt")
add_subdirectory(${SDK_PATH} ha_remotedevice)
else()
message(FATAL_ERROR "ha_remotedevice SDK not found at ${SDK_PATH}")
endif()
# 创建设备适配器可执行文件
add_executable(${PROJECT_NAME}
main.c
)
# 链接 SDK
target_link_libraries(${PROJECT_NAME} PRIVATE ha_remotedevice)
# 包含 SDK 头文件
target_include_directories(${PROJECT_NAME} PRIVATE
${HA_REMOTEDEVICE_INCLUDE_DIR}
)
# 编译选项
if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
target_compile_options(${PROJECT_NAME} PRIVATE
-Wall -Wextra -Wpedantic
-Wno-unused-parameter
)
endif()
# 安装
install(TARGETS ${PROJECT_NAME} RUNTIME DESTINATION bin)
`
const tmplReadme = `# {{.Plg.Name}}
{{.Plg.Description}}
## Build
` + "```bash" + `
hmapdev build
` + "```" + `
## Install
Upload the .hmap file through the Plugin Manager API.
`

View File

@ -0,0 +1,24 @@
# {{.Plg.Name}}
{{.Plg.Description}}
## Build
```bash
plugindev build
```
## Install
Upload the `.hmap` file through the Plugin Manager API:
```bash
curl -X POST http://localhost:8080/api/v1/plugins \
-H "Content-Type: application/octet-stream" \
--data-binary @dist/<name_en_snake>_linux_amd64.hmap
```
## Lifecycle
- `RegisterStopHandler` — runs on every stop (including reload/disable), before `Stop()`.
- `RegisterOnRemoveHandler` — runs **only on uninstall (remove)**, after `Stop()`; clean up the plugin's own data files here. Reload/disable do NOT trigger it. See the onRemove demo in `main.go`.

View File

@ -0,0 +1,61 @@
package main
import (
"encoding/json"
"fmt"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
type Plugin struct {
name string
sdk *sdk.PluginSDK
}
func NewPlugin(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.RegisterStopHandler(func() {
fmt.Printf("[%s] stop handler running\n", p.name)
})
s.RegisterOnRemoveHandler(func() {
fmt.Printf("[%s] onRemove handler running\n", p.name)
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "plugin.{{.Plg.Name}}.example",
Default: "hello",
Type: "string",
DisplayName: "示例配置",
Description: "An example configuration key",
Category: "{{.Plg.Name}}",
})
tp := p.name + "_"
s.RegisterTool(tp+"hello", sdk.ToolDef{
Name: tp + "hello",
Description: "A hello world tool",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
}, p.handleHello)
fmt.Printf("[%s] started\n", p.name)
return nil
}
func (p *Plugin) Stop() error {
fmt.Printf("[%s] stopped\n", p.name)
return nil
}
func (p *Plugin) handleHello(args map[string]interface{}) (interface{}, error) {
return map[string]interface{}{
"content": "Hello from {{.Plg.Name}} plugin!",
}, nil
}

View File

@ -0,0 +1,45 @@
local plugin = {
name = "{{.Plg.Name}}",
tools = {},
stages = {},
settings = {}
}
function plugin:start(sdk)
sdk.log("{{.Plg.Name}} plugin starting...")
-- Register a configuration setting
-- sdk.settings.register({
-- key = "{{.Plg.Name}}.example",
-- default = "hello",
-- type = "string",
-- display_name = "Example Config",
-- description = "An example configuration key"
-- })
-- Register a tool
local ok = sdk:register_tool("{{.Plg.Name}}_hello", {
name = "{{.Plg.Name}}_hello",
description = "A hello world tool",
parameters = {
type = "object",
properties = {}
}
}, function(args)
return { content = "Hello from {{.Plg.Name}} plugin!" }
end)
if not ok then
sdk.log("error: failed to register tool")
return false
end
sdk.log("{{.Plg.Name}} plugin started")
return true
end
function plugin:stop()
return true
end
return plugin

View File

@ -0,0 +1,11 @@
{
"name": "{{.Plg.Name}}",
"name_zh": "{{.Plg.NameZh}}",
"name_en": "{{.Plg.NameEn}}",
"version": "{{.Plg.Version}}",
"description": "{{.Plg.Description}}",
"author": "{{.Plg.Author}}",
"entry": "{{.Plg.Entry}}",
"tags": {{.Plg.Tags}},
"targets": "{{.Plg.Targets}}"
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,54 @@
//go:build linux || darwin || freebsd
package main
import (
"fmt"
"os"
"syscall"
)
// Unix 侧共享段挂载:内核经 ExtraFiles 传入继承的 fd。
//
// 统一共享内存区域布局§13.1
//
// fd 3 = 统一区域SuperBlock + StageContext + EvtRing
// fd 4 = 事件通知Linux eventfd / macOS pipe 读端)
//
// 继承的 fd 无需文件名,也不残留——这是选 memfd 而非 /dev/shm 的原因。
const (
fdUnifiedShm = 3
fdEvtNotifier = 4
)
// attachUnifiedShm 挂载统一共享内存区域。
//
// 各进程 mmap 到不同虚拟地址,段内一律用相对偏移而非指针,故仍能正确解引用
// (实验 2 已验证父子 mmap 基址不同时偏移解引用正确)。
func attachUnifiedShm(size int) ([]byte, error) {
return syscall.Mmap(fdUnifiedShm, 0, size,
syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_SHARED)
}
// openEvtNotifier 打开事件通知读端。
func openEvtNotifier() (evtWaiter, error) {
f := os.NewFile(fdEvtNotifier, "evtnotify")
if f == nil {
return nil, fmt.Errorf("fd %d 不是有效的通知句柄", fdEvtNotifier)
}
return &unixEvtWaiter{f: f}, nil
}
// unixEvtWaiter 用 eventfd/pipe 的阻塞 Read 等待通知。
//
// os.NewFile 把 fd 注册进 runtime netpollerRead 阻塞时只 park goroutine
// 不占 OS 线程(实验 1200 个等待者仅增 1 个 OS 线程)。
// 反面对照是经 cgo 调 sem_wait——那会阻塞整个 M。
type unixEvtWaiter struct {
f *os.File
}
func (w *unixEvtWaiter) Wait(buf []byte) error {
_, err := w.f.Read(buf)
return err
}

View File

@ -0,0 +1,153 @@
//go:build windows
package main
import (
"fmt"
"os"
"syscall"
"unsafe"
)
// Windows 侧共享段挂载:走命名对象而非继承 fd。
//
// 为何不能照抄 UnixWindows 没有 fd 继承语义,`ExtraFiles` 在 os/exec 的
// Windows 实现里不被支持。等价机制是命名内核对象——父进程用
// CreateFileMapping / CreateEvent 建带名字的对象,子进程按同名 Open 拿到同一对象。
//
// 名字经环境变量传入(内核 internal/plugin/proc/plugin_windows.go 设置),
// 而不是硬编码:多个 homed 实例并存时不能撞名。
//
// **这是 §9.2 的正解**C ABI 时代 Windows 是第三套独立 ABI 实现,
// stage 只下发 3 个字段且完全没有写回sanitizer 这类改写型插件静默失效。
// 现在 Windows 与 Unix 共用同一份 RPC 逻辑与同一份共享段布局,
// 差异被收敛到本文件的三个函数里。
const (
envStageShmName = "HOMEAGENT_SHM_STAGE"
envEvtRingName = "HOMEAGENT_SHM_EVTRING"
envEvtEventName = "HOMEAGENT_EVT_EVENT"
)
// Windows API 绑定:用 LazyDLL 而非 golang.org/x/sys/windows。
//
// 原因OpenFileMappingW / OpenEventW 未被标准库 syscall 包导出。
// 引入 x/sys 会给**每个插件的 go.mod 加一个新依赖**
// 而「外部插件零改动」是本次迁移的硬约束(插件仅依赖公开 SDK
// LazyDLL 属于标准库 syscall零新增依赖。
var (
kernel32 = syscall.NewLazyDLL("kernel32.dll")
procOpenFileMappingW = kernel32.NewProc("OpenFileMappingW")
procOpenEventW = kernel32.NewProc("OpenEventW")
)
const (
winEventModifyState = 0x0002
winSynchronize = 0x00100000
)
// openFileMappingW 封装 OpenFileMappingW。
func openFileMappingW(access uint32, inherit bool, name *uint16) (syscall.Handle, error) {
var inheritFlag uintptr
if inherit {
inheritFlag = 1
}
r, _, err := procOpenFileMappingW.Call(
uintptr(access), inheritFlag, uintptr(unsafe.Pointer(name)))
if r == 0 {
return 0, err
}
return syscall.Handle(r), nil
}
// openEventW 封装 OpenEventW。
func openEventW(access uint32, inherit bool, name *uint16) (syscall.Handle, error) {
var inheritFlag uintptr
if inherit {
inheritFlag = 1
}
r, _, err := procOpenEventW.Call(
uintptr(access), inheritFlag, uintptr(unsafe.Pointer(name)))
if r == 0 {
return 0, err
}
return syscall.Handle(r), nil
}
// attachStageShm 按名字打开 StageContext 段并映射。
func attachStageShm(size int) ([]byte, error) {
return openNamedMapping(os.Getenv(envStageShmName), size, "StageContext 段")
}
// attachEvtRingShm 按名字打开事件环段并映射。
func attachEvtRingShm(size int) ([]byte, error) {
return openNamedMapping(os.Getenv(envEvtRingName), size, "事件环段")
}
// openNamedMapping 打开命名共享段并映射为 []byte。
//
// 与 Unix 的 mmap 语义对齐MapViewOfFile 返回的地址在本进程虚拟空间,
// 段内偏移仍是相对的,故跨进程解引用正确。
func openNamedMapping(name string, size int, what string) ([]byte, error) {
if name == "" {
return nil, fmt.Errorf("%s 名字未经环境变量传入", what)
}
namePtr, err := syscall.UTF16PtrFromString(name)
if err != nil {
return nil, fmt.Errorf("%s 名字非法: %w", what, err)
}
h, err := openFileMappingW(syscall.FILE_MAP_WRITE, false, namePtr)
if err != nil {
return nil, fmt.Errorf("打开 %s%s: %w", what, name, err)
}
addr, err := syscall.MapViewOfFile(h, syscall.FILE_MAP_WRITE, 0, 0, uintptr(size))
if err != nil {
syscall.CloseHandle(h)
return nil, fmt.Errorf("映射 %s: %w", what, err)
}
// 句柄不关:视图存活期间必须保持句柄有效,进程退出时由 OS 回收。
return unsafe.Slice((*byte)(unsafe.Pointer(addr)), size), nil
}
// openEvtNotifier 按名字打开事件通知对象。
func openEvtNotifier() (evtWaiter, error) {
name := os.Getenv(envEvtEventName)
if name == "" {
return nil, fmt.Errorf("事件通知对象名字未经环境变量传入")
}
namePtr, err := syscall.UTF16PtrFromString(name)
if err != nil {
return nil, fmt.Errorf("事件对象名字非法: %w", err)
}
h, err := openEventW(winSynchronize|winEventModifyState, false, namePtr)
if err != nil {
return nil, fmt.Errorf("打开事件对象(%s: %w", name, err)
}
return &windowsEvtWaiter{h: h}, nil
}
// windowsEvtWaiter 用命名 Event 对象等待通知。
//
// 与 eventfd 的差异Event 是二元信号而非计数器,多次 SetEvent 只对应
// 一次唤醒。这不影响正确性——消费者被唤醒后按 readSeq 追 writeSeq
// 批量 drain一次唤醒能处理累积的全部事件。
//
// WaitForSingleObject 阻塞的是 OS 线程而非仅 goroutine故不如 eventfd
// 的 netpoller 路径省线程。每插件一个消费 goroutine17 插件即 17 线程,
// 在可接受范围(实验 5 实测 17 子进程共 84 线程)。
type windowsEvtWaiter struct {
h syscall.Handle
}
func (w *windowsEvtWaiter) Wait(buf []byte) error {
ev, err := syscall.WaitForSingleObject(w.h, syscall.INFINITE)
if err != nil {
return err
}
if ev != syscall.WAIT_OBJECT_0 {
return fmt.Errorf("等待事件对象返回 0x%x", ev)
}
return nil
}

View File

@ -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"
"gitcode.com/JianFeeeee/homeagent-sdk/tools/hmapdev/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 <name> <json>' 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
}

View File

@ -0,0 +1,537 @@
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:"-"`
ContextPolicy string `json:"context_policy,omitempty"`
}
// 上下文策略取值,与公共 SDK 一致。
const (
ContextPolicyNone = "none"
ContextPolicyPrune = "prune"
)
// InjectOptions 与公共 SDK 同构:声明一次注入是否记入记忆、是否据此裁剪上下文、
// 以及用哪个已注册的通道 cleaner 清洗注入内容。
type InjectOptions struct {
NoMemory bool
ContextPolicy string
CleanerName string
}
type IOInjector interface {
InjectInterruptText(source, channel, text string)
InjectText(source, channel, text string)
InjectTextNoMemory(source, channel, text string)
// InjectInputSync 注入输入事件并同步等待 agent 回复(无回复时返回空串)。
// 通道类插件qq / a2a 等)靠它完成「收到入站 → agent 处理 → 回复取回」闭环,
// 而 mock 此前只有带 flags 的 InjectInputSyncOpts、没有这个零值糖——
// 于是一个能在 plugin.bin 里编译通过、在 yaegi 下却调不通的方法就长住了。
InjectInputSync(source, channel, text string) string
// 1.1.0 媒体注入。与公共 SDK 同构:插件在 yaegi 下调得通的方法,
// 编成 plugin.bin 后必须也调得通,否则调试期与真实运行行为不一致。
InjectInputMedia(source, channel, text string, blocks []ContentBlock)
InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string
InjectInterruptMedia(source, channel, text string, blocks []ContentBlock)
SetToolBlocks(blocks []ContentBlock)
// 1.2.0 带标志位的注入,与公共 SDK 同构。
InjectTextOpts(source, channel, text string, opts InjectOptions)
InjectInterruptTextOpts(source, channel, text string, opts InjectOptions)
InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string
InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string
InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
}
// ContentBlock 与公共 SDK 同构OpenAI 多模态内容块格式)。
type ContentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *ImageURL `json:"image_url,omitempty"`
AudioURL *AudioURL `json:"audio_url,omitempty"`
}
type ImageURL struct {
URL string `json:"url"`
Detail string `json:"detail,omitempty"`
}
type AudioURL struct {
URL string `json:"url"`
}
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"`
}
// Triple 与公共 SDK 同构。
//
// ❗字段名曾是 `Predicate`,而公共 SDK 一直叫 `Relation`。
// yaegi 解释器下插件写 `Relation:` 会报未知字段,写 `Predicate:` 则在
// 编成 plugin.bin 时报错——谁都不对。没人发现是因为没有任何代码
// 对着 mocksdk 编译,漂移不会被编译器抓到。
type Triple struct {
Subject string `json:"subject"`
Relation string `json:"relation"`
Object string `json:"object"`
Confidence float64 `json:"confidence,omitempty"`
SubjectType string `json:"subject_type,omitempty"`
ObjectType string `json:"object_type,omitempty"`
SentenceText string `json:"sentence_text,omitempty"`
MediaDigests []string `json:"media_digests,omitempty"`
}
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"`
// 1.1.0:媒体字段。与公共 SDK 保持同构,否则插件在 yaegi 下跑得通、
// 编成 plugin.bin 却编不过(或反之)。
MediaDigests []string `json:"media_digests,omitempty"`
Attachments []MediaAttachment `json:"attachments,omitempty"`
}
// MediaAttachment 与公共 SDK 同构:写入时给 Data+MIME引用已有内容时只给 Digest。
type MediaAttachment struct {
Digest string `json:"digest,omitempty"`
MIME string `json:"mime,omitempty"`
Data []byte `json:"data,omitempty"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
}
type DocMemoryAPI interface {
Query(text string, topK int) []*Doc
Insert(doc *Doc) error
InsertWithMedia(doc *Doc, attachments []MediaAttachment) 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) InsertWithMedia(doc *Doc, atts []MediaAttachment) error {
logf("doc_insert_with_media: %d 份附件", len(atts))
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"`
// 1.1.0:附件。读回时内核从正文标记反解,写入时内核把标记并进正文。
Attachments []MediaAttachment `json:"attachments,omitempty"`
}
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)
}
func (IOInjectorImpl) InjectInputMedia(source, channel, text string, blocks []ContentBlock) {
logf("inject_input_media: source=%s channel=%s blocks=%d", source, channel, len(blocks))
}
func (IOInjectorImpl) InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string {
logf("inject_input_media_sync: source=%s channel=%s blocks=%d", source, channel, len(blocks))
return ""
}
func (IOInjectorImpl) InjectInterruptMedia(source, channel, text string, blocks []ContentBlock) {
logf("inject_interrupt_media: source=%s channel=%s blocks=%d", source, channel, len(blocks))
}
func (IOInjectorImpl) SetToolBlocks(blocks []ContentBlock) {
logf("set_tool_blocks: blocks=%d", len(blocks))
}
// ---- 带 InjectOptions 的注入 ----
func (IOInjectorImpl) InjectInputSync(source, channel, text string) string {
logf("inject_sync: source=%s channel=%s", source, channel)
return ""
}
func (IOInjectorImpl) InjectTextOpts(source, channel, text string, opts InjectOptions) {
logf("inject_text_opts: source=%s channel=%s no_memory=%v policy=%s", source, channel, opts.NoMemory, opts.ContextPolicy)
}
func (IOInjectorImpl) InjectInterruptTextOpts(source, channel, text string, opts InjectOptions) {
logf("inject_interrupt_opts: source=%s channel=%s no_memory=%v policy=%s", source, channel, opts.NoMemory, opts.ContextPolicy)
}
func (IOInjectorImpl) InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string {
logf("inject_sync_opts: source=%s channel=%s no_memory=%v policy=%s", source, channel, opts.NoMemory, opts.ContextPolicy)
return ""
}
func (IOInjectorImpl) InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) {
logf("inject_input_media_opts: source=%s channel=%s blocks=%d no_memory=%v policy=%s", source, channel, len(blocks), opts.NoMemory, opts.ContextPolicy)
}
func (IOInjectorImpl) InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string {
logf("inject_input_media_sync_opts: source=%s channel=%s blocks=%d no_memory=%v policy=%s", source, channel, len(blocks), opts.NoMemory, opts.ContextPolicy)
return ""
}
func (IOInjectorImpl) InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) {
logf("inject_interrupt_media_opts: source=%s channel=%s blocks=%d no_memory=%v policy=%s", source, channel, len(blocks), opts.NoMemory, opts.ContextPolicy)
}
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)
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))
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)
s.mu.Lock()
defer s.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) {
s.mu.RLock()
handler, ok := s.toolHandlers[name]
s.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("tool not found: %s", name)
}
return handler(args)
}
func (s *PluginSDK) CallStage(stage string, ctx *StageContext) error {
s.mu.RLock()
handler, ok := s.stageHandlers[stage]
s.mu.RUnlock()
if !ok {
return nil
}
return handler(ctx)
}
func (s *PluginSDK) ListTools() []ToolDef {
s.mu.RLock()
defer s.mu.RUnlock()
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)
}