Files
homeagent-sdk/tools/hmapdev/cmd_skill_test.go
JianFeeeee 29c61f0e07 feat(hmapdev): skill 子命令 —— 插件开发知识随 SDK 分发
## 为什么需要

HomeAgent 插件开发的知识(hmapdev 工具链、SDK 版本坑、plg.json、
plugin.bin 部署、内置 vs 独立二进制)此前只存在于**某个 agent 的对话历史**里。
本机四个 agent(pi / claude / codex / .agents)都不会开发插件,
因为它们**没有任何渠道**拿到这些知识。

知识属于 SDK(与工具链同源、随 SDK 版本走),所以:
- 源:`skills/`(随 SDK 仓分发)
- 装:`hmapdev skill install`

## 命令

    hmapdev skill list              列出活跃 SDK 里的 skills
    hmapdev skill install [name...] 装到各 agent 的 skills 目录
    hmapdev skill path               显示源目录与来源

安装目标(**只装这些**,不认识的目录不创建):

    ~/.pi/agent/skills   ~/.claude/skills
    ~/.codex/skills       ~/.agents/skills

## 两条策略,与别处**故意相反**

1. **总是覆盖**。skill 是**工具生成物**,不是用户数据。保留用户改过的版本
   会让它与工具链脱节 —— 而工具链的命令面会随版本变。
   (对比:`hmapdev build` 写出的适配器更新会**保护**用户修改,
   因为那是运行时代码;skill 只是说明文档。)
2. **不扫 glob 自动发现**。不认识的目录建出来也没用,
   还会让用户以为装上了。

## 源目录:仓库优先,store 回退

store(`~/.homeagent/hmapdev/sdk/<v>/`)是 `sdk install` 复制的**副本**。
而 skill 是纯文档、加它不需要动 SDK 的编译产物 ——
「改了 skill 却要重装 SDK 才能生效」对日常维护不合理。

故优先用 hmapdev **自身所在仓库**的 `skills/`(靠可执行文件位置反推,
并校验 go.mod 的 module 是 homeagent-sdk),落空才回退 store。

## 判据 9 条(cmd_skill_test.go)

覆盖:目标无重复/不逃出 home、**真实仓库**里 skills/ 的布局合规、
隐藏目录与普通文件不算 skill、幂等、覆盖用户修改、跳过未知目标、
真实入口 `cmdSkillInstall` 覆盖用户修改、未知 skill 名不静默。

### 写判据时踩的三个坑

1. **`TestSkillSourceHasValidLayout` 原用 `t.TempDir()`** ⇒ 那个目录是空的,
   判据永远红,且红得毫无意义("文件不存在"是真的,但真实文件在 SDK 仓里)。
   改为指向**真实仓库**。
2. **fixture 造错**:我 `MkdirAll` 出一个叫 `README.md` 的**目录**,
   于是实现"正确地"把它当 skill,判据却报「把 README.md 当成了 skill」。
   是 fixture 错,不是实现错。
3. ★ **`TestSkillInstallOverwritesUserEdit` 只验 `copyDir`、没走真实入口**
   ⇒ 我把 `cmdSkillInstall` 里的 copyDir 换成「已存在就跳过」,
   **判据依然全绿**。变异测试抓出来的。补 `TestCmdSkillInstallOverwritesUserEdit`
   走真实入口后,变异立刻变红。

## 实测

删掉三处 skill 后 `hmapdev skill install` 一条命令装回四处,
四处 md5 与源一致,二次安装结果不变(幂等)。

## 门禁

- `go test ./...`(hmapdev 独立 module):ok,0 FAIL
- `go build ./...`:ok
- 9 条判据全通过,变异测试确认能抓回归
2026-09-28 11:23:58 +08:00

354 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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/<name>/`),且安装时**总是覆盖**,不留"用户改过就保留"
// 的分支 —— 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("不请<E4B88D><E8AFB7> 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")
}
}