Files
homeagent-sdk/tools/hmapdev/cmd_skill.go
JianFeeeee e0ee0c43bb fix(hmapdev): SDK 模块识别要认新旧两个 module 名(迁移漏改的逻辑判定)
上一提交(b6a6168)只替换了 import 路径,**漏了用旧 module 名做逻辑判定
的五处**。CI 当场抓到一处(`TestPrintVersionReportsInjectedVersion`),
但那是测试断言;另外三处是**产品逻辑**,CI 抓不到,是静默失效。

## 漏改的地方

| 位置 | 原判定 | 失效后果 |
|---|---|---|
| `cmd_build.go` | `Contains(mod,"homeagent-sdk")` | 认不出 SDK 依赖 ⇒ **不写 replace** |
| `cmd_skill.go` | `Contains(gm,"homeagent-sdk")` | 认不出 SDK 仓 ⇒ 不安装 skills |
| `yaegi/interp.go` ×2 | `Contains(line,"homeagent-sdk")` | `findSDKGoPath` 找不到 SDK ⇒ 退化到兜底 |

迁移后这些 `Contains` **恒为假**(新名是 `homeagentsdk`,没有连字符)。
三者都**不报错**,只是行为悄悄变成另一条分支——比直接崩更难发现。

## 关键:不能只认新名

本机实测存量插件工作区的 go.mod **全部仍是旧名**:

```
/home/program/qq-plugin            (无 go.mod)
/home/program/vanblog-plugin       require gitcode.com/JianFeeeee/homeagent-sdk
/home/program/mcquery-plugin       require gitcode.com/JianFeeeee/homeagent-sdk
/home/program/fileproc-plugin      require gitcode.com/JianFeeeee/homeagent-sdk
/home/program/agentmail/plugins/   require gitcode.com/JianFeeeee/homeagent-sdk
```

用户已创建的项目不会因为我们改仓就自动更新。所以判定必须
**两种都接受**,而不是把旧名换成新名(那只是把 bug 从"认不出新"换成"认不出旧")。

## 改动

- `meta` 补 `SDKModule` / `LegacySDKModule` 常量(原注释引用了 `SDKModule`
  但常量并不存在——注释先于实现)
- 新增 `tools/hmapdev/sdkmodule.go`:`isSDKModule` / `sdkModulePath` /
  `containsSDKModule`
- 新增 `tools/hmapdev/yaegi/sdkmodule.go`:同语义的最小副本
  (yaegi 是独立子包,不能 import 父包)
- 三处逻辑判定改用上述助手

## 判据(可杀变异,逐个实测)

`sdkmodule_test.go` + `yaegi/sdkmodule_test.go`:覆盖新名/旧名/带版本号的
旧名/空串/核心仓(`HomeAgent`,不是 SDK)/无关模块。

这三个判定点**此前零测试覆盖**——这正是它们能静默失效的原因。

变异验证:
- 只认新名 → FAIL「旧名漏认会让存量插件静默走错分支」
- `containsSDKModule` 只认新名 → FAIL「必须认旧名」

## 验证

- SDK 根 module + hmapdev module:`go build` / `go test` 全通过
- 20 个 example `go vet` 全通过
- `go mod verify` → all modules verified
2026-10-09 07:32:00 +08:00

279 lines
8.2 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 (
"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 <command>
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 <version>".
· 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/<v>/)是 `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/。
// 形如 <sdk>/tools/hmapdev/hmapdev ⇒ <sdk>/skills
func repoSkillsFromExe(exe string) string {
dir := filepath.Dir(exe) // <sdk>/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 &&
containsSDKModule(string(gm)) {
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
}