diff --git a/skills/homeagent-plugin-dev/SKILL.md b/skills/homeagent-plugin-dev/SKILL.md new file mode 100644 index 0000000..0893eca --- /dev/null +++ b/skills/homeagent-plugin-dev/SKILL.md @@ -0,0 +1,205 @@ +--- +name: homeagent-plugin-dev +description: 开发、安装、调试 HomeAgent 插件。涉及 hmapdev 工具链、SDK 版本、plugin.bin 部署、插件注册排查时使用。触发词:HomeAgent 插件、hmapdev、plugin.bin、写插件、装插件、插件不生效、SDK 版本、RegisterTool、plugin_spawn。 +--- + +# HomeAgent 插件开发与安装 + +> 本机(192.168.2.60)HomeAgent 的插件工具链手册。**所有命令都实测过**, +> 不是从文档抄的。改了本机环境后请回来更正。 + +## 0. 先搞清三件事,别猜 + +```bash +hmapdev version # 工具链版本 + SDK 模块 + 构建用 Go +hmapdev sdk current # 当前活跃 SDK 版本 +hmapdev sdk list # 本机已装的所有 SDK 版本 +``` + +**核心仓在** `/home/program/TrueAgent`(Go module `gitcode.com/JianFeeeee/HomeAgent`)。 +**SDK 仓在** `third_party/homeagent-sdk`(独立 git 仓,独立版本号)。 + +## 1. 工具链:hmapdev + +已装在 `/usr/local/bin/hmapdev`。这是插件开发的**唯一入口**。 + +| 命令 | 作用 | +| --- | --- | +| `hmapdev init ` | 生成 Go 插件脚手架 | +| `hmapdev init --lua` | 生成 Lua 插件 | +| `hmapdev init --type remotedevice` | 生成 C 语言远程设备适配器 | +| `hmapdev build` | 编译打包(`--outdir` / `--target os/arch`) | +| `hmapdev debug [dir]` | 解释执行 / 调试插件源码 | +| `hmapdev clean` | 清理 build/dist | +| `hmapdev sdk list/install/use/path/current/latest` | SDK 版本管理 | + +⚠ **`hmapdev build` / `debug` 不支持 `--help`**:它们**不是**打印帮助,而是直接 +去读当前目录的 `plg.json`,于是你会看到 + + error: read plg.json: open plg.json: no such file or directory + +这**不是故障**,只是没有 `--help`。要看 build 的可用 flag 用 `hmapdev --help` +(那里列出了 `--outdir` / `--target` / `--lua` / `--type`)。 + +## 2. ★ SDK 版本:最常见的坑 + +**插件编译时会校验 SDK 能力**,用**文档里的旧接口**生成的工程会直接构建失败, +报错形如「某能力需要更高版本 SDK」,并给出两条出路: + +```bash +hmapdev sdk install # 1) 装对应版本 +hmapdev sdk install --from <本地源码目录> # 2) 直接用本地 SDK 源码 +``` + +本机现状:`hmapdev sdk current` = **v1.4.0**,与核心仓 `internal/meta.Version` 一致。 + +⇒ **动手前先 `hmapdev sdk current`**,别照着旧文档写。 +⇒ 核心仓有**新能力但 SDK 未跟上**时(本机发生过),用 `--from` 指向本地源码: + `hmapdev sdk install --from /home/program/TrueAgent/third_party/homeagent-sdk` + +## 3. 开发流程 + +```bash +mkdir -p /home/newqqagent/plugindev && cd /home/newqqagent/plugindev +hmapdev init myplugin +cd myplugin +# 编辑源码:注册工具用 s.RegisterTool(name, sdk.ToolDef{...}, handler) +hmapdev build # 产出 dist/myplugin_bundle.hmap +hmapdev debug . # 不想装就能先跑一遍 +``` + +### ★ 产物形态(实测,别猜) + +`hmapdev build` 产出的是 **`dist/_bundle.hmap`**,它是 **ZIP** +(魔数 `PK`,用 `unzip` 而不是 `tar` 解),内含多平台二进制: + + plugin.json + plugin.bin.linux.amd64 + plugin.bin.darwin.amd64 + README.md + +而**生产上** `/home/newqqagent/plugins//plugin.bin` 是**解包后的单个二进制** +(**不是** zip)。⇒ 部署时要用对应平台的 `plugin.bin..`, +不要把 `.hmap` 直接丢进去。 + +### 注册工具的形状(照 example 写,别自创) + +```go +s.RegisterTool(tp+"mytool", sdk.ToolDef{ + Name: tp + "mytool", // ← Name 必填,且要放在结构体**首位** + Description: "……", + Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}, + Handler: h.mytool, + NoMemory: true, // 声明字段一律放**末尾**,仿 NoMemory 的写法 +}, s) +``` + +**并发声明**(同轮多个 tool_call 时): + +- `ParallelSafe: true` —— 可并发(**三个条件都满足才可声明**:handler 线程安全 / + 不与同批工具争抢同一资源 / 执行顺序无关) +- `Serial: true` —— 必须串行,**优先级高于 `ParallelSafe`** +- **默认(都不写)= 整批串行**,是保守设计不是遗漏 + +## 3.5 `plg.json` —— 插件工程标志 + +`hmapdev build` 找的就是它。缺失时报 +`error: read plg.json: open plg.json: no such file or directory`。 + +以 `example/qq/plg.json` 为准: + +```json +{ + "name": "qq", // 插件名(英文,装到 plugins// 用它) + "name_zh": "QQ消息", // 展示名 + "version": "1.4.1", + "description": "……", + "author": "HomeAgent", + "entry": "plugin.so", // 入口(build 会替换为实际产物) + "tags": ["qq", "messaging"], + "targets": "linux/amd64", // 目标平台 + "outdir": "dist" // 产物目录 +} +``` + +## 4. 安装到生产 + +```bash +# ① 从 bundle 里取出本机平台的二进制 +cd myplugin && unzip -o dist/myplugin_bundle.hmap 'plugin.bin.linux.amd64' +# ② 放进插件目录( 要与 plg.json 的 name 一致) +sudo mkdir -p /home/newqqagent/plugins/myplugin +sudo cp plugin.json /home/newqqagent/plugins/myplugin/ +sudo cp plugin.bin.linux.amd64 /home/newqqagent/plugins/myplugin/plugin.bin +sudo chmod +x /home/newqqagent/plugins/myplugin/plugin.bin +# ③ 重启并确认工具真的注册了 +sudo systemctl restart homeagent.service +journalctl -u homeagent.service --since "-2 min" | grep "registering tool: <你的工具名>" +``` + +`plugin.json` 是**安装标志**(生产目录里没有它,插件不会被识别)。 + +★ **内置 vs 独立二进制**(目录存在 ≠ 有 plugin.bin): + +```bash +for p in webui qq cmd seq; do + printf "%-8s " $p + ls /home/newqqagent/plugins/$p/plugin.bin >/dev/null 2>&1 \ + && echo "独立(要单独构建部署)" || echo "内置(随 homed 部署)" +done +``` + +实测:`webui`/`cmd`/`seq` 内置,`qq` 独立。 + +## 5. 排查:插件装了却不生效 + +按这个顺序查,**别跳步**: + +```bash +# 1) 内置还是独立?内置的改了源码必须重编 homed,不是重启就行 +ls /home/newqqagent/plugins//plugin.bin + +# 2) 工具注册了吗 +journalctl -u homeagent.service --since "-5 min" | grep "registering tool:" | grep -i + +# 3) 插件加载了吗 +journalctl -u homeagent.service --since "-5 min" | grep -E "loaded: " + +# 4) 启动时有没有报错 +journalctl -u homeagent.service --since "-5 min" | grep -iE ".*(error|panic|failed)" +``` + +★ **改了内置插件的源码 ⇒ 必须重新构建并部署 homed**,重启服务不生效。 +内嵌资源走 `//go:embed`,是编译进二进制的。 + +★ 判断线上跑的是哪次构建,看 commit 字段(**不要用 `strings`** —— +`//go:embed` 的资源在旧二进制里也可能出现新内容): + +```bash +K=$(sqlite3 /home/newqqagent/config.db "select value from config_webui where key='api_key';") +curl -s -H "X-API-Key: $K" http://127.0.0.1:8080/api/v1/status | grep -oE '"commit":"[^"]*"' +``` + +⚠ 必须带 `X-API-Key`:无认证时返回 **200 + 登录页 HTML**,只看状态码会误判。 + +## 6. 参考资料在哪(别凭记忆写 API) + +| 内容 | 路径 | +| --- | --- | +| SDK 源码 | `third_party/homeagent-sdk/sdk/` | +| **可运行的示例插件** | `third_party/homeagent-sdk/example/`(a2a / acp / qq / bili …) | +| 文档站 | `https://sdk.homeagent.jianfgit.xyz`(本地源 `docs/`,构建 `tools/apidoc/build.sh`) | +| 能力边界(哪些 API 外部可用) | `docs/guide/capability-boundary.md` | +| 并发声明 | `docs/guide/parallel-tool-declaration.md` | +| Lua 适配器写法 | `docs/guide/first-lua-plugin.md` | +| 部署手册 | 核心仓 `docs/zh/deploy-runbook.md` | + +★ **写插件前先翻 `example/`**:那里的代码是**编译通过**的, +比文档更可靠。文档与示例冲突时以示例为准。 + +## 7. 边界(别越界) + +- **不要手改生成的文档**:`docs/api/*.md` 由 `tools/apidoc/gensite` 生成, + 首行写着「请勿手改」;要改就去改 `sdk/*.go` 的注释再重新生成。 +- **不要在插件里硬编码 token / 密钥**:走 `Settings()` 或宿主注入。 +- **不要为了让插件生效去改 `homed` 的源码** —— 先确认它是不是内置插件。 diff --git a/tools/hmapdev/cmd_skill.go b/tools/hmapdev/cmd_skill.go new file mode 100644 index 0000000..3842366 --- /dev/null +++ b/tools/hmapdev/cmd_skill.go @@ -0,0 +1,278 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// hmapdev skill —— 把 SDK 里的插件开发知识装到各 agent 的 skills 目录。 +// +// ## 为什么需要这个命令 +// +// HomeAgent 插件开发的知识(hmapdev 工具链、SDK 版本坑、plg.json、 +// plugin.bin 部署、内置 vs 独立二进制)此前只存在于**某个 agent 的对话历史**里。 +// 本机四个 agent 都不会开发插件,因为它们没有任何渠道拿到这些知识。 +// +// 知识属于 SDK(与工具链同源、随 SDK 版本走),所以源在 SDK 仓的 `skills/`, +// 由本命令分发。 +// +// ## 两条策略,与别处**故意相反** +// +// 1. **总是覆盖**。skill 是**工具生成物**,不是用户数据。 +// 保留用户改过的版本会让它与工具链脱节 —— 而工具链的命令面会随版本变。 +// (对比:`hmapdev build` 写出的适配器更新会**保护**用户修改, +// 因为那是运行时代码;skill 只是说明文档。) +// 2. **只装已实测存在的目标**,不扫 glob。不认识的目录建出来也没用, +// 还会让用户以为装上了。 + +// skillTarget 描述一个 agent 的 skills 目录。 +type skillTarget struct { + name string + rel string // 相对 home +} + +// knownSkillTargets 是已实测存在的 agent skills 目录。 +var knownSkillTargets = []skillTarget{ + {"pi", ".pi/agent/skills"}, + {"claude", ".claude/skills"}, + {"codex", ".codex/skills"}, + {"agents", ".agents/skills"}, +} + +func skillHelp() { + fmt.Println(`hmapdev skill + +Distribute HomeAgent plugin-development knowledge to agent skill directories. + +Commands: + list List skills shipped with the active SDK + install [name...] Install skills into agent skill directories + path Show the skills source directory of the active SDK + +Install targets (only these; unknown directories are not created): + ~/.pi/agent/skills ~/.claude/skills + ~/.codex/skills ~/.agents/skills + +Notes: + · Source is the **active SDK**'s skills/ directory. Pick the SDK first with + "hmapdev sdk use ". + · Installation always **overwrites**: a skill is a tool-generated artifact, + not user data. Keeping user edits would let it drift from the toolchain. + · Idempotent: installing twice yields the same result.`) +} + +func cmdSkill(args []string) { + if len(args) == 0 { + skillHelp() + return + } + switch args[0] { + case "list": + cmdSkillList() + case "install": + cmdSkillInstall(args[1:]) + case "path": + cmdSkillPath() + case "-h", "--help", "help": + skillHelp() + default: + fmt.Printf("unknown skill command: %s\n\n", args[0]) + skillHelp() + os.Exit(1) + } +} + +// skillsSourceDir 返回 SDK 内的 skills 源目录。 +func skillsSourceDir(sdkRoot string) string { + return filepath.Join(sdkRoot, "skills") +} + +// resolveSkillsSource 定位 skill 源目录。 +// +// 优先用**hmapdev 自己所在仓库**的 skills/,回退到 store 里的活跃 SDK。 +// +// ★ 为什么需要这个回退顺序 +// +// store(~/.homeagent/hmapdev/sdk//)是 `hmapdev sdk install` 复制的**副本**。 +// 而 skill 是纯文档、加它不需要动 SDK 的编译产物 —— 于是「改了 skill 却要 +// 重装 SDK 才能生效」,对日常维护很不合理。 +// +// hmapdev 在 SDK 仓里用 `go build` 构建时,它自身就在仓库内 +// (tools/hmapdev → ../../skills),此时仓库是**最新的**,应当优先。 +// 发布出去的二进制不在仓库里,两条路径都落空时才报错。 +func resolveSkillsSource() (string, string) { + // 1) hmapdev 自身所在仓库 + if exe, err := os.Executable(); err == nil { + if p := repoSkillsFromExe(exe); p != "" { + return p, "仓库(hmapdev 构建自 SDK 源)" + } + } + // 2) store 里的活跃 SDK + if root := activeSDKRoot(); root != "" { + p := skillsSourceDir(root) + if _, err := os.Stat(p); err == nil { + return p, "SDK store(" + root + ")" + } + } + return "", "" +} + +// repoSkillsFromExe 从 hmapdev 可执行文件位置反推 SDK 仓根,再取 skills/。 +// 形如 /tools/hmapdev/hmapdev ⇒ /skills +func repoSkillsFromExe(exe string) string { + dir := filepath.Dir(exe) // /tools/hmapdev + // go.mod 声明 go1.21 ⇒ 不能用 range-over-int(需 1.22) + for i := 0; i < 3; i++ { + cand := filepath.Join(dir, "skills") + if st, err := os.Stat(cand); err == nil && st.IsDir() { + // 确认这确实像 SDK 仓(有 go.mod 且 module 是 homeagent-sdk) + if gm, err := os.ReadFile(filepath.Join(dir, "go.mod")); err == nil && + strings.Contains(string(gm), "homeagent-sdk") { + return cand + } + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return "" +} + +func cmdSkillPath() { + dir, origin := resolveSkillsSource() + if dir == "" { + fmt.Println("error: 找不到 skills 目录") + fmt.Println(" 装一个带 skills 的 SDK:hmapdev sdk install latest") + skillHelp() + os.Exit(1) + } + fmt.Printf("%s\n 来源:%s\n", dir, origin) +} + +func cmdSkillList() { + src, origin := resolveSkillsSource() + if src == "" { + fmt.Println("error: 找不到 skills 目录") + fmt.Println(" 装一个带 skills 的 SDK:hmapdev sdk install latest") + os.Exit(1) + } + names, err := readSkillNames(src) + if err != nil { + fmt.Printf("error: read %s: %v\n", src, err) + fmt.Println(" If this SDK predates skills/, upgrade: hmapdev sdk install latest") + os.Exit(1) + } + if len(names) == 0 { + fmt.Printf("no skills in %s\n", src) + return + } + fmt.Printf("Skills in %s(%s):\n", src, origin) + for _, n := range names { + fmt.Printf(" %s\n", n) + } + fmt.Println() + fmt.Println("Install with: hmapdev skill install") +} + +func cmdSkillInstall(names []string) { + home, err := os.UserHomeDir() + if err != nil { + fmt.Printf("error: cannot determine home directory: %v\n", err) + os.Exit(1) + } + src, origin := resolveSkillsSource() + if src == "" { + fmt.Println("error: 找不到 skills 目录") + fmt.Println(" 装一个带 skills 的 SDK:hmapdev sdk install latest") + os.Exit(1) + } + available, err := readSkillNames(src) + if err != nil { + fmt.Printf("error: read %s: %v\n", src, err) + fmt.Println(" If this SDK predates skills/, upgrade: hmapdev sdk install latest") + os.Exit(1) + } + if len(available) == 0 { + fmt.Printf("no skills to install from %s\n", src) + return + } + + // 不给名字就全装 + want := names + if len(want) == 0 { + want = available + } + // 显式拒绝不存在的 skill,而不是静默跳过 —— 静默跳过会让用户以为装上了 + for _, w := range want { + found := false + for _, a := range available { + if a == w { + found = true + break + } + } + if !found { + fmt.Printf("error: no such skill: %s\n", w) + fmt.Printf(" available: %s\n", strings.Join(available, ", ")) + os.Exit(1) + } + } + + // 只装到**已存在**的目标目录。 + // + // 这里与判据里的"不建未知目录"配套:对不存在的目录直接报告, + // 让用户自己确认路径,而不是静默创建一个可能没人读的空目录。 + installed, skipped := 0, 0 + for _, w := range want { + from := filepath.Join(src, w) + for _, tt := range knownSkillTargets { + base := filepath.Join(home, tt.rel) + if _, err := os.Stat(base); os.IsNotExist(err) { + skipped++ + continue + } + to := filepath.Join(base, w) + if err := copyDir(from, to); err != nil { + fmt.Printf("error: install %s -> %s: %v\n", w, tt.name, err) + os.Exit(1) + } + fmt.Printf(" %-8s %s\n", tt.name, to) + installed++ + } + } + fmt.Printf("\nsource: %s(%s)\n", src, origin) + fmt.Printf("installed %d skill(s) into %d target(s)", len(want), installed) + if skipped > 0 { + fmt.Printf("; skipped %d target(s) whose directory does not exist", skipped) + } + fmt.Println() + if installed == 0 { + fmt.Println(" No agent skills directory found. Create one of:") + for _, tt := range knownSkillTargets { + fmt.Printf(" %s\n", filepath.Join(home, tt.rel)) + } + os.Exit(1) + } +} + +// readSkillNames 列出 skills/ 下的 skill 名(跳过隐藏目录与非目录)。 +func readSkillNames(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + var out []string + for _, e := range entries { + if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { + continue + } + out = append(out, e.Name()) + } + sort.Strings(out) + return out, nil +} diff --git a/tools/hmapdev/cmd_skill_test.go b/tools/hmapdev/cmd_skill_test.go new file mode 100644 index 0000000..37df8d5 --- /dev/null +++ b/tools/hmapdev/cmd_skill_test.go @@ -0,0 +1,353 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// hmapdev skill install 的判据。 +// +// ## 为什么要有这个命令 +// +// 2026-09-28:HomeAgent 插件开发的知识(hmapdev 工具链、SDK 版本坑、 +// plg.json、plugin.bin 部署、内置 vs 独立二进制)只存在于**agent 的对话历史**里。 +// 本机四个 agent(pi / claude / codex / .agents)都不会开发插件, +// 因为它们**没有任何渠道**拿到这些知识。 +// +// 知识属于 SDK(跟工具链同源、随 SDK 版本走),所以: +// · 源在 SDK 仓的 `skills/` +// · 装到各 agent 的 skills 目录由 `hmapdev skill install` 负责 +// +// ## 三条设计约束(都来自实际踩过的坑) +// +// 1. **skill 要带版本**。SDK 与插件是协议绑定,工具链同理: +// 旧 SDK 里的 skill 描述的命令面可能已经变了。所以 skill 目录名带 +// 版本(`skills//`),且安装时**总是覆盖**,不留"用户改过就保留" +// 的分支 —— skill 是**工具生成物**,不是用户数据。 +// +// 2. **只装已知的 agent 目录**。agent 的 skills 路径没有统一标准, +// 我们只能装到已实测存在的四个;不认识的目录宁可报告也不乱建。 +// +// 3. **幂等**。重复安装必须得到同一结果,否则「重装工具链」会 +// 在用户不知情时产生差异。 +// +// 运行:go test ./tools/hmapdev/ -run TestSkill -v + +// skillTarget 描述一个 agent 的 skills 目录。 +// repoSkillsDir 返回**真实仓库**里 skills/ 的路径。 +// 测试文件在 tools/hmapdev/,往上两级是 SDK 仓根。 +func repoSkillsDir(t *testing.T) string { + t.Helper() + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + return filepath.Join(wd, "..", "..", "skills") +} + +// knownSkillTargets 定义在 cmd_skill.go(实现与判据共用一份,避免漂移)。 + +func TestSkillTargetsHaveNoDuplicates(t *testing.T) { + seen := map[string]string{} + for _, tt := range knownSkillTargets { + if prev, dup := seen[tt.rel]; dup { + t.Errorf("skills 路径重复:%s 与 %s 同为 %s", prev, tt.name, tt.rel) + } + seen[tt.rel] = tt.name + } +} + +func TestSkillTargetsAreRelativeAndUnderHome(t *testing.T) { + for _, tt := range knownSkillTargets { + if filepath.IsAbs(tt.rel) { + t.Errorf("%s: rel 必须是相对路径(相对 home),得到 %s", tt.name, tt.rel) + } + if strings.HasPrefix(tt.rel, "..") { + t.Errorf("%s: rel 不能逃出 home:%s", tt.name, tt.rel) + } + if !strings.HasPrefix(tt.rel, ".") { + t.Errorf("%s: rel 应以点开头的隐藏目录(agent 配置都在 ~/.X 下):%s", tt.name, tt.rel) + } + } +} + +// skillsSourceDir 定义在 cmd_skill.go。 + +// listSkillNamesForTest 判据自带的列举(要 t.Fatalf,故不复用实现的)。 +// ★ 刻意**不复用** readSkillNames:那条路径在出错时 os.Exit,判据里没法接。 +func listSkillNamesForTest(t *testing.T, dir string) []string { + t.Helper() + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("读 skills 目录 %s: %v", dir, err) + } + var out []string + for _, e := range entries { + if !e.IsDir() { + continue + } + // 隐藏目录(.git 之类)不是 skill + if strings.HasPrefix(e.Name(), ".") { + continue + } + out = append(out, e.Name()) + } + return out +} + +// 每个 skill 必须是「目录 + SKILL.md」,且 frontmatter 有 name/description。 +// 这是各 agent 都能识别的最小契约(实测本机四个 agent 都用这个格式)。 +func TestSkillSourceHasValidLayout(t *testing.T) { + // ★ 必须验**真实仓库**里的 skills/,而不是 TempDir。 + // 原先用 TempDir ⇒ 那个目录是空的,判据永远红,且红得毫无意义 + // ("文件不存在"是真的,但真实文件在 SDK 仓里,不在临时目录)。 + src := repoSkillsDir(t) + for _, name := range []string{"homeagent-plugin-dev"} { + dir := filepath.Join(src, name) + if _, err := os.Stat(dir); os.IsNotExist(err) { + t.Fatalf("缺少 skill 目录 %s", dir) + } + md := filepath.Join(dir, "SKILL.md") + b, err := os.ReadFile(md) + if err != nil { + t.Fatalf("缺少 %s: %v", md, err) + } + s := string(b) + if !strings.HasPrefix(s, "---\n") { + t.Errorf("%s: 缺少 YAML frontmatter 起始", md) + } + if !strings.Contains(s, "\nname:") { + t.Errorf("%s: frontmatter 缺 name 字段", md) + } + if !strings.Contains(s, "\ndescription:") { + t.Errorf("%s: frontmatter 缺 description 字段", md) + } + // description 是 agent 判断"要不要读这个 skill"的唯一依据, + // 空描述等于 skill 装了也不会被触发。 + for _, line := range strings.Split(s, "\n") { + if strings.HasPrefix(line, "description:") { + if len(strings.TrimSpace(strings.TrimPrefix(line, "description:"))) < 10 { + t.Errorf("%s: description 太短,agent 可能不会触发", md) + } + } + } + } +} + +func TestListSkillNamesIgnoresHidden(t *testing.T) { + home := t.TempDir() + src := skillsSourceDir(home) + // skill 目录 + if err := os.MkdirAll(filepath.Join(src, "homeagent-plugin-dev"), 0o755); err != nil { + t.Fatal(err) + } + // 隐藏目录(.git 之类)不该被当成 skill + if err := os.MkdirAll(filepath.Join(src, ".git"), 0o755); err != nil { + t.Fatal(err) + } + // 普通文件(README.md)不是目录,不该被当成 skill + // ★ 原先我把它 MkdirAll 成了**目录** —— 于是实现"正确地"把它当 skill, + // 判据却报"把 README.md 当成了 skill"。是 fixture 造错了,不是实现错。 + if err := os.WriteFile(filepath.Join(src, "README.md"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + names := listSkillNamesForTest(t, src) + for _, n := range names { + if n == ".git" || n == "README.md" { + t.Errorf("listSkillNames 把 %q 当成了 skill", n) + } + } + found := false + for _, n := range names { + if n == "homeagent-plugin-dev" { + found = true + } + } + if !found { + t.Errorf("listSkillNames 漏掉了 homeagent-plugin-dev,得到 %v", names) + } +} + +// 幂等:连续安装两次,结果必须一致。 +func TestSkillInstallIsIdempotent(t *testing.T) { + home := t.TempDir() + src := skillsSourceDir(home) + dir := filepath.Join(src, "homeagent-plugin-dev") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + body := "---\nname: homeagent-plugin-dev\ndescription: 开发 HomeAgent 插件的工具链手册\n---\n\n内容\n" + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + + install := func() map[string]string { + out := map[string]string{} + for _, tt := range knownSkillTargets { + dst := filepath.Join(home, tt.rel, "homeagent-plugin-dev") + if err := copyDir(dir, dst); err != nil { + t.Fatalf("copy: %v", err) + } + b, err := os.ReadFile(filepath.Join(dst, "SKILL.md")) + if err != nil { + t.Fatalf("读回: %v", err) + } + out[tt.name] = string(b) + } + return out + } + a := install() + b := install() + if len(a) != len(knownSkillTargets) { + t.Fatalf("装到的目标数不对:%d", len(a)) + } + for name, content := range a { + if b[name] != content { + t.Errorf("%s: 二次安装结果不同(不幂等)", name) + } + } +} + +// 覆盖而非保留:skill 是**工具生成物**,用户改了会在下次安装被覆盖。 +// 这是与适配器更新(保护用户修改)**相反**的策略,必须显式钉住。 +func TestSkillInstallOverwritesUserEdit(t *testing.T) { + home := t.TempDir() + src := skillsSourceDir(home) + dir := filepath.Join(src, "homeagent-plugin-dev") + _ = os.MkdirAll(dir, 0o755) + want := "---\nname: homeagent-plugin-dev\ndescription: 工具链手册\n---\n原始内容\n" + _ = os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(want), 0o644) + + dst := filepath.Join(home, knownSkillTargets[0].rel, "homeagent-plugin-dev") + _ = os.MkdirAll(dst, 0o755) + _ = os.WriteFile(filepath.Join(dst, "SKILL.md"), + []byte("---\nname: homeagent-plugin-dev\ndescription: 用户改过\n---\n用户的版本\n"), 0o644) + + if err := copyDir(dir, dst); err != nil { + t.Fatalf("copy: %v", err) + } + got, err := os.ReadFile(filepath.Join(dst, "SKILL.md")) + if err != nil { + t.Fatal(err) + } + if string(got) != want { + t.Errorf("skill 安装未覆盖用户修改。\n skill 是**工具生成物**(随 SDK 版本走),"+ + "保留用户版本会让它与工具链脱节。\n 得到:%q", string(got)) + } +} + +// 未知的 agent 目录不建:用户可能有自己的目录布局,乱建等于污染。 +func TestSkillInstallSkipsUnknownTargets(t *testing.T) { + home := t.TempDir() + src := skillsSourceDir(home) + dir := filepath.Join(src, "homeagent-plugin-dev") + _ = os.MkdirAll(dir, 0o755) + _ = os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("---\nname: x\ndescription: yyy\n---\n"), 0o644) + + // 模拟:只请求装到 pi(不装 claude) + _ = copyDir(dir, filepath.Join(home, knownSkillTargets[0].rel, "homeagent-plugin-dev")) + if _, err := os.Stat(filepath.Join(home, ".claude")); err == nil { + t.Error("不请�� claude 却创建了 ~/.claude") + } +} + +// ★ 关键补充:TestSkillInstallOverwritesUserEdit 验的是 **copyDir**, +// +// **没走真实入口**。我据此以为判据覆盖了「安装时覆盖用户修改」, +// 实际把 cmdSkillInstall 里的 copyDir 换成「已存在就跳过」, +// 判据依然全绿 —— 变异测试抓出来的。 +// +// 这条走**真实入口** cmdSkillInstall。 +func TestCmdSkillInstallOverwritesUserEdit(t *testing.T) { + home := t.TempDir() + store := filepath.Join(home, "sdkstore") + sdkRoot := filepath.Join(store, "v9.9.9") + src := skillsSourceDir(sdkRoot) + dir := filepath.Join(src, "homeagent-plugin-dev") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + want := "---\nname: homeagent-plugin-dev\ndescription: 工具链手册,覆盖测试\n---\n原始内容\n" + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(want), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(store, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(store, "current"), []byte("v9.9.9"), 0o644); err != nil { + t.Fatal(err) + } + + // 预置一个「用户改过」的版本 + piSkills := filepath.Join(home, ".pi", "agent", "skills") + if err := os.MkdirAll(piSkills, 0o755); err != nil { + t.Fatal(err) + } + existing := filepath.Join(piSkills, "homeagent-plugin-dev") + if err := os.MkdirAll(existing, 0o755); err != nil { + t.Fatal(err) + } + userVer := "---\nname: homeagent-plugin-dev\ndescription: 用户改过的描述文字\n---\n用户的版本\n" + if err := os.WriteFile(filepath.Join(existing, "SKILL.md"), []byte(userVer), 0o644); err != nil { + t.Fatal(err) + } + + t.Setenv("HOME", home) + t.Setenv("HOMEAGENT_SDK_DIR", store) + + cmdSkillInstall(nil) + + got, err := os.ReadFile(filepath.Join(existing, "SKILL.md")) + if err != nil { + t.Fatal(err) + } + if string(got) == userVer { + t.Errorf("cmdSkillInstall 未覆盖用户修改的 skill。\n" + + " skill 是**工具生成物**(内容随工具链命令面变化)," + + "保留用户版本会让它与工具链脱节。") + } +} + +// cmdSkillInstall 对不存在的 skill 名必须**报错退出**,不是静默跳过 —— +// 静默跳过会让用户以为装上了。 +func TestCmdSkillInstallRejectsUnknownSkill(t *testing.T) { + home := t.TempDir() + store := filepath.Join(home, "sdkstore") + src := skillsSourceDir(filepath.Join(store, "v9.9.9")) + if err := os.MkdirAll(filepath.Join(src, "homeagent-plugin-dev"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(store, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(store, "current"), []byte("v9.9.9"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", home) + t.Setenv("HOMEAGENT_SDK_DIR", store) + + // 不该 panic 也不该静默返回;实现里是 os.Exit(1),在测试里会终止进程, + // 所以这里只验「不 panic 且没有产出任何文件」。 + defer func() { + if r := recover(); r != nil { + t.Fatalf("未知 skill 名导致 panic 而非报错退出: %v", r) + } + }() + // 这里不真调(会 os.Exit);改为直接验 readSkillNames 的行为: + // 不存在的名字在 available 里找不到 ⇒ 调用方必须报错。 + names, err := readSkillNames(src) + if err != nil { + t.Fatal(err) + } + found := false + for _, n := range names { + if n == "no-such-skill" { + found = true + } + } + if found { + t.Error("readSkillNames 返回了不存在的 skill") + } +} diff --git a/tools/hmapdev/main.go b/tools/hmapdev/main.go index 2d9a5d7..09540f4 100644 --- a/tools/hmapdev/main.go +++ b/tools/hmapdev/main.go @@ -25,6 +25,8 @@ func main() { cmdDebug(os.Args[2:]) case "sdk": cmdSDK(os.Args[2:]) + case "skill": + cmdSkill(os.Args[2:]) case "version", "-v", "--version": printVersion() default: @@ -69,6 +71,7 @@ Usage: hmapdev clean Clean build/dist artifacts hmapdev debug [dir] Interpret and debug plugin source hmapdev sdk Manage SDK versions + hmapdev skill Install plugin-dev skills into agent skill dirs Flags: --outdir Output directory (default: dist)