Files
homeagent-sdk/tools/hmapdev/skills.go
JianFeeeee 0111ac3a7a fix(sdk): 协议通道纪律 + hmapdev 模板升级 + 示例插件 stdout 污染
## 核心缺陷:插件往 stdout 打日志 = 污染 JSON-RPC 协议通道

子进程插件与内核用 stdout 传 NDJSON 帧,stderr 才是日志通道。
脚手架模板(及 4 个示例插件)用 `fmt.Printf` 打日志 ⇒ 内核收到非 JSON 文本:

    [proc] weather 收到非法 JSON 帧(21 字节): invalid character 'i'

**生产实测 1038 条**(huawei_smarthome 720 / weather 113 / rss 103 / calendar 102)。
后果不只是日志噪音:那一行日志被**丢弃**,插件看起来"没启动"。

修法:
- hmapdev 模板 `fmt.Printf` → `log.Printf`(9 处)
- 示例插件 4 个 14 处:ai_image(2) calendar(5) rss(4) weather(3)
  + 补 "log" import
- 内核侧(主仓)同步让报错可定位:内容摘要 + 可能原因 + 修法

判别法:`package main` = 子进程(协议通道,禁用 stdout 打日志);
普通库包 = 进程内插件(编译进内核,写真实终端,**不是**缺陷)——
`internal/plugins/cli` 的 `fmt.Println` 属后者。

## hmapdev

- `cmd_build.go` +136:平台产物精确匹配(`--target` 是**append** 语义,
  原脚本 `ls dist/*.hmap | head -1` 会按字典序抓到 amd64,
  **导致 arm64 包里装的是 x86-64 二进制**,且退出码 0、校验和通过)
- `cmd_init.go` +46:脚手架 stdio 纪律
- `templates/proc_main.go.tmpl`:补齐 mgrStop/mgrStart/mgrIsRuntimeStopped
  三态能力(插件管理器运行态停启)
- `templates/plg.json.tmpl` 删除(合并进 cmd_init)

## 判据

- `scaffold_stdout_test.go`(3 条):扫模板生成的代码不得写 stdout。
  ★ 判据自身先假红过一次:**模板的注释里就写了 `fmt.Printf`** ⇒
  必须 `stripTemplateComments()` 之后才判
- `example_stdout_test.go`(2 条):扫全部 20 个子进程示例插件,
  报错给**文件:行号**;变异咬住并给出准确行号
- `package_examples_test.go`(2 条):**执行真实打包脚本**
  (第一版测的是 hmapdev 而不是脚本 ⇒ 变异不咬)
- `cmd_build` / `cmd_init` 新判据

## 其他

- sdk/settings.go +15(三态能力透传)
- `docs/versions.md` +§示例插件协议通道纪律(含生产 1038 条实测表格)
- `skills/homeagent-plugin-dev/SKILL.md` +§协议通道纪律(正误代码对比)
2026-10-17 16:34:29 +08:00

130 lines
4.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
// 随包 skill 的打包(`skill/` 目录)。
//
// ## 为什么与 `rawfile/` 分开,而不是放进 rawfile/
//
// 两者**意图不同**:
//
// rawfile/ 任意随包资源(node 二进制、pi 依赖、配置模板…)——内核只是解开
// skill/ 要**登记给 skillmgr** 的纯 SKILL 条目 —— 内核安装后会移交注册
//
// 若把 skill 放在 rawfile/ 下(如 `rawfile/skills/x/SKILL.md`),内核就必须
// 靠**子路径名**去猜哪些是要注册的 —— 那是隐式约定,正是本仓反复吃过亏的
// 模式(例:config.db 因"默认 0644"这个隐式假设而长期明文)。
// 显式目录让意图在**结构**里可见。
//
// ## 解包形态
//
// 与 rawfile 一样**保留路径**(不拍平):
//
// <插件目录>/skill/<name>/SKILL.md
//
// 内核侧(pluginmgr)扫 `<插件目录>/skill/*` 并把每个目录发
// skill_detected 事件,由 skillmgr 接管注册(见
// internal/plugins/pluginmgr/skill_handout.go)。
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// SkillDir 是工程根下存放随包 skill 的目录(与 rawfile/ 并列)。
const SkillDir = "skill"
// SkillZipPrefix 是解包后这些条目的统一前缀。
//
// 与 SkillDir 同值 —— 保留这个独立常量是为了让"打包目录名"与
// "包内前缀"可以分别演进(rawfile 那边是两个同名常量,同样的理由)。
const SkillZipPrefix = "skill"
// collectSkillFiles 收集 `skill/` 目录下的条目。
//
// 与 collectRawFiles 的差别:
// - **只收一层**:skill 是「每个子目录一个 skill」,嵌套不再是 skill。
// 递归收会让 `skill/a/.git/SKILL.md` 这种被当成 skill 发出去。
// - **只收目录**:单文件 skill 不在本约定内(skillmgr 支持,
// 但随包分发统一用目录形态,避免两种形态都要判)。
// - 仍然排除脚手架(.gitkeep/.gitignore)。
func collectSkillFiles(root string) ([]rawEntry, error) {
dir := filepath.Join(root, SkillDir)
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
// 没有 skill/ 是**常态**(绝大多数插件不带),不是错误
return nil, nil
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("读取 %s/ 失败: %w", SkillDir, err)
}
var out []rawEntry
for _, e := range entries {
name := e.Name()
if strings.HasPrefix(name, ".") {
continue // 脚手架/隐藏项
}
if !e.IsDir() {
// 明确拒绝而不是静默跳过:放在 skill/ 下的文件几乎肯定是放错了
//(要么该是 skill/<name>/SKILL.md,要么该放 rawfile/)。
return nil, fmt.Errorf(
"%s/%s 是文件;%s/ 下只接受**目录**(每个目录一个 skill,形如 %s/<name>/SKILL.md)"+
";普通资源请放 rawfile/",
SkillDir, name, SkillDir, SkillDir)
}
sub := filepath.Join(dir, name)
// 每个 skill 目录下的文件**递归保留结构**
// (有的 skill 带 references/、assets/ 等子目录)。
err := filepath.Walk(sub, func(p string, fi os.FileInfo, werr error) error {
if werr != nil {
return werr
}
if fi.IsDir() {
return nil
}
rel, err := filepath.Rel(dir, p)
if err != nil {
return err
}
if IsRawFileScaffold(rel) {
return nil
}
out = append(out, rawEntry{
src: p,
zip: filepath.ToSlash(filepath.Join(SkillZipPrefix, rel)),
})
return nil
})
if err != nil {
return nil, fmt.Errorf("展开 %s/%s 失败: %w", SkillDir, name, err)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].zip < out[j].zip })
return out, nil
}
// skillFilesSummary 给构建输出一行摘要(0 个时不打印)。
func skillFilesSummary(ents []rawEntry) string {
if len(ents) == 0 {
return ""
}
// 数 skill 个数(一层目录名去重)而不只是文件数 —— 后者对用户无意义
names := map[string]bool{}
for _, e := range ents {
rest := strings.TrimPrefix(e.zip, SkillZipPrefix+"/")
if i := strings.Index(rest, "/"); i > 0 {
names[rest[:i]] = true
}
}
var list []string
for n := range names {
list = append(list, n)
}
sort.Strings(list)
return fmt.Sprintf("%d 个 skill(%s,共 %d 个文件)",
len(list), strings.Join(list, ", "), len(ents))
}