feat(skillmgr): 原生技能管理器插件 + OpenClaw 兼容层职责分离

新增 internal/plugins/skillmgr(native skill 全生命周期 owner):
- skill_list/info/load/unload/enable/disable/create/export/install
- skill_create 两步式:先生成骨架模板,LLM 补全后传 content 覆盖写入
  (plugin.ValidateSKILLContent 校验)并自动加载生效
- .skm 分发包(tar.gz):packSkill/unpackSkill 含 TarSlip 防护
  (拒绝绝对路径/../逃逸、强制单根目录、校验包内 SKILL.md)
- skills 目录扫描:纯 SKILL.md/skill.json 条目归本插件;
  sidecar(main.js/main.py)/OC plugin(openclaw.plugin.json) 留给兼容层

clawhubadapter 职责分离(OpenClaw 兼容层不再持有 native skill):
- 删除 p.skills 字段与 default 分支 LoadSKILL 逻辑
- 发现纯 SKILL 条目改为发布 events.EventSkillDetected 移交事件,
  由 skillmgr 订阅注册;启动时序 c<s 下全扫兜底,事件用于热新增
- claw_list/plugin_info 不再输出 SKILL 段,统一走 skill_list

方案B prompt 注入:
- agentCore 新增 SkillIndexProvider 接口 + SetSkillIndexProvider
- buildSystemPrompt 注入【可用技能】轻量索引(名称+版本+描述),
  LLM 匹配场景时主动 skill_info 拉全文按文档执行
- main.go 在插件加载后将 skillmgr 实例接线到 agent

内核小修:
- extractDescription 跳过 YAML frontmatter 块(此前所有带 frontmatter
  的 SKILL.md 描述都被误判为 '---')
- extractField 剥离 YAML 成对引号(version: "1.0" 不再带尾引号)
- plugin.ValidateSKILLContent 导出供生成侧校验
This commit is contained in:
JianFeeeee
2026-08-25 20:25:57 +08:00
parent 51eb98e0ae
commit 5f126d4d10
12 changed files with 1454 additions and 299 deletions

View File

@ -0,0 +1,266 @@
package skillmgr
import (
"archive/tar"
"compress/gzip"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// packSkill 把 skill 目录打包为 .skmtar.gz。返回打包的文件数。
// 包内路径统一为 <skillName>/<相对路径>,解包端按首段目录还原。
func packSkill(srcDir, outPath string) (int, error) {
info, err := os.Stat(srcDir)
if err != nil {
return 0, fmt.Errorf("stat skill dir: %w", err)
}
if !info.IsDir() {
// 单 .md 文件 skill包装成只含一个文件的包
return packSingleFile(srcDir, outPath)
}
skillName := filepath.Base(srcDir)
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
return 0, err
}
f, err := os.Create(outPath)
if err != nil {
return 0, fmt.Errorf("create pack: %w", err)
}
defer f.Close()
gz := gzip.NewWriter(f)
defer gz.Close()
tw := tar.NewWriter(gz)
defer tw.Close()
count := 0
err = filepath.Walk(srcDir, func(path string, fi os.FileInfo, err error) error {
if err != nil {
return err
}
// 跳过隐藏文件与 node_modules
rel, rerr := filepath.Rel(srcDir, path)
if rerr != nil {
return rerr
}
if rel == "." {
return nil
}
for _, part := range strings.Split(rel, string(filepath.Separator)) {
if strings.HasPrefix(part, ".") || part == "node_modules" {
if fi.IsDir() {
return filepath.SkipDir
}
return nil
}
}
hdr, err := tar.FileInfoHeader(fi, "")
if err != nil {
return err
}
hdr.Name = filepath.ToSlash(filepath.Join(skillName, rel))
if fi.IsDir() {
hdr.Name += "/"
if err := tw.WriteHeader(hdr); err != nil {
return err
}
return nil
}
if !fi.Mode().IsRegular() {
return nil // 跳过符号链接等非常规文件,避免路径逃逸
}
if err := tw.WriteHeader(hdr); err != nil {
return err
}
src, err := os.Open(path)
if err != nil {
return err
}
_, err = io.Copy(tw, src)
src.Close()
if err != nil {
return err
}
count++
return nil
})
if err != nil {
return 0, fmt.Errorf("pack walk: %w", err)
}
return count, nil
}
func packSingleFile(mdPath, outPath string) (int, error) {
skillName := strings.TrimSuffix(filepath.Base(mdPath), ".md")
data, err := os.ReadFile(mdPath)
if err != nil {
return 0, err
}
f, err := os.Create(outPath)
if err != nil {
return 0, err
}
defer f.Close()
gz := gzip.NewWriter(f)
defer gz.Close()
tw := tar.NewWriter(gz)
defer tw.Close()
hdr := &tar.Header{
Name: filepath.ToSlash(filepath.Join(skillName, SkillFileName)),
Mode: 0644,
Size: int64(len(data)),
}
if err := tw.WriteHeader(hdr); err != nil {
return 0, err
}
if _, err := tw.Write(data); err != nil {
return 0, err
}
return 1, nil
}
// unpackSkill 解压 .skm 到临时目录后整体 rename 为 dstParent。
// 校验包内所有条目必须位于同一首段目录下,防止 TarSlip 路径逃逸。
// 返回解出的文件数。
func unpackSkill(packPath, dstParent string) (int, error) {
f, err := os.Open(packPath)
if err != nil {
return 0, err
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return 0, fmt.Errorf("not a gzip pack: %w", err)
}
defer gz.Close()
tw := tar.NewReader(gz)
var root string // 包内唯一根目录名
count := 0
tmpDir, err := os.MkdirTemp(filepath.Dir(dstParent), ".skillmgr-unpack-")
if err != nil {
return 0, err
}
defer os.RemoveAll(tmpDir)
for {
hdr, err := tw.Next()
if err == io.EOF {
break
}
if err != nil {
return count, fmt.Errorf("read tar: %w", err)
}
// 清洗路径:拒绝绝对路径与 .. 逃逸
name := filepath.Clean(filepath.FromSlash(hdr.Name))
if filepath.IsAbs(name) || strings.HasPrefix(name, "..") {
return count, fmt.Errorf("unsafe entry in pack: %q", hdr.Name)
}
parts := strings.Split(name, string(filepath.Separator))
if len(parts) < 2 {
return count, fmt.Errorf("entry outside skill root dir: %q", hdr.Name)
}
if root == "" {
root = parts[0]
} else if parts[0] != root {
return count, fmt.Errorf("multiple root dirs in pack: %q vs %q", root, parts[0])
}
target := filepath.Join(tmpDir, name)
if hdr.Typeflag == tar.TypeDir {
if err := os.MkdirAll(target, 0755); err != nil {
return count, err
}
continue
}
if hdr.Typeflag != tar.TypeReg {
continue // 跳过符号链接等
}
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return count, err
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(hdr.Mode)&0755)
if err != nil {
return count, err
}
_, err = io.Copy(out, tw)
out.Close()
if err != nil {
return count, err
}
count++
}
if root == "" {
return 0, fmt.Errorf("empty pack")
}
// 校验解包结果是合法 skill有 SKILL.md 或 skill.json
unpackedRoot := filepath.Join(tmpDir, root)
if !fileExists(filepath.Join(unpackedRoot, SkillFileName)) && !fileExists(filepath.Join(unpackedRoot, MetaFileName)) {
return count, fmt.Errorf("pack has no %s or %s at its root", SkillFileName, MetaFileName)
}
// rename 到期望位置(包内根名与期望名不同时先改根目录名再落位)
src := unpackedRoot
if filepath.Base(dstParent) != root {
src = filepath.Join(tmpDir, ".renamed")
if err := os.Rename(unpackedRoot, src); err != nil {
return count, err
}
}
if err := os.Rename(src, dstParent); err != nil {
return count, err
}
return count, nil
}
// copyDir 递归复制目录(跳过隐藏文件与 node_modules
func copyDir(src, dst string) error {
return filepath.Walk(src, func(path string, fi os.FileInfo, err error) error {
if err != nil {
return err
}
rel, rerr := filepath.Rel(src, path)
if rerr != nil {
return rerr
}
if rel == "." {
return os.MkdirAll(dst, 0755)
}
for _, part := range strings.Split(rel, string(filepath.Separator)) {
if strings.HasPrefix(part, ".") || part == "node_modules" {
if fi.IsDir() {
return filepath.SkipDir
}
return nil
}
}
target := filepath.Join(dst, rel)
if fi.IsDir() {
return os.MkdirAll(target, 0755)
}
if !fi.Mode().IsRegular() {
return nil
}
in, err := os.Open(path)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, fi.Mode())
if err != nil {
return err
}
_, err = io.Copy(out, in)
out.Close()
return err
})
}

View File

@ -0,0 +1,268 @@
// Package skillmgr 实现原生 SKILL 插件管理器。
//
// 职责边界:
// - 本插件是 native skillSKILL.md / skill.json 指令文档型插件)的唯一归属者,
// 负责其全生命周期:加载、卸载、启停、生成(模板)、导出(.skm 包)、安装。
// - clawhubadapterOpenClaw 兼容层)扫描 skills 目录时发现纯 SKILL 条目后
// 发布 skill_detected 事件移交本插件注册sidecar/OC plugin 不归本插件管。
//
// skill 的运行模型SKILL.md 是给 LLM 的指令文档agent 通过 skill_info 读取全文后
// 用 cmd_run 等工具按文档执行skill 不注册可调用 handler 工具。
package skillmgr
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
"gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
const (
// SkillFileName 标准 skill 主文件名
SkillFileName = "SKILL.md"
// MetaFileName 可选元数据文件(覆盖 SKILL.md frontmatter 字段)
MetaFileName = "skill.json"
// PackExt 导出包扩展名
PackExt = ".skm"
// detectEvent 兼容层移交事件类型
detectEvent = events.EventSkillDetected
)
func init() {
plugin.RegisterPluginMeta("skillmgr", "技能管理器", "Skill Manager")
plugin.RegisterFactory("skillmgr", func(name string, cfg map[string]interface{}) (sdk.Plugin, error) {
dir := ""
if dataDir, ok := cfg["data_dir"].(string); ok && dataDir != "" {
dir = filepath.Join(dataDir, "skills")
}
return New(name, dir), nil
})
}
// SkillEntry 一个已加载的 native skill 及其实时状态。
type SkillEntry struct {
sk *plugin.SKILLPlugin
path string
enabled bool
}
type Plugin struct {
name string
skillsDir string
mu sync.RWMutex
skills map[string]*SkillEntry // name -> entry
sdk *sdk.PluginSDK
unsub func() // skill_detected 订阅注销
}
func New(name, skillsDir string) *Plugin {
return &Plugin{
name: name,
skillsDir: skillsDir,
skills: map[string]*SkillEntry{},
}
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s
// 默认目录:内核 data_dir 下 skills 目录(与配置 core.daemon.data_dir 对齐)
if p.skillsDir == "" {
if v, _ := s.Settings().GetCore("daemon.data_dir"); v != nil {
if dir, ok := v.(string); ok && dir != "" {
p.skillsDir = filepath.Join(dir, "skills")
}
}
}
if err := os.MkdirAll(p.skillsDir, 0755); err != nil {
return fmt.Errorf("mkdir skills dir: %w", err)
}
// 订阅兼容层移交事件clawhubadapter 发现纯 SKILL 后发布)
p.unsub = s.Subscribe(detectEvent, func(evt *events.Event) {
path, _ := evt.Payload["path"].(string)
if path == "" {
return
}
if _, err := p.loadOne(path); err != nil {
log.Printf("[skillmgr] detected skill %s: %v", path, err)
return
}
log.Printf("[skillmgr] registered via %s event: %s", detectEvent, filepath.Base(path))
})
// 启动全扫:兜底接管所有已存在的纯 SKILL 目录/单文件
// (时序上 skillmgr(c<s) 先于 clawhubadapter 启动,此处先扫到的条目
// 后续事件重复到达时 loadOne 幂等跳过)
n := p.scanExisting()
p.registerTools()
log.Printf("[skillmgr] started, dir=%s, loaded %d native skills", p.skillsDir, n)
return nil
}
func (p *Plugin) Stop() error {
if p.unsub != nil {
p.unsub()
p.unsub = nil
}
return nil
}
// scanExisting 扫描 skills 目录加载全部纯 SKILL 条目。返回加载数量。
func (p *Plugin) scanExisting() int {
entries, err := os.ReadDir(p.skillsDir)
if err != nil {
return 0
}
count := 0
for _, e := range entries {
if strings.HasPrefix(e.Name(), ".") || e.Name() == "node_modules" {
continue
}
path := filepath.Join(p.skillsDir, e.Name())
if !isNativeSkillPath(path) {
continue
}
if _, err := p.loadOne(path); err != nil {
log.Printf("[skillmgr] scan skip %s: %v", e.Name(), err)
continue
}
count++
}
return count
}
// isNativeSkillPath 判断路径是否为纯 SKILL 类型(非 sidecar 非 OC plugin
// sidecar: main.js/main.pyOC plugin: openclaw.plugin.json 或带 openclaw 扩展的 package.json。
func isNativeSkillPath(path string) bool {
info, err := os.Stat(path)
if err != nil {
return false
}
if !info.IsDir() {
return strings.HasSuffix(strings.ToLower(path), ".md")
}
// 目录内含 sidecar / OC plugin 标志文件的归兼容层管
for _, flag := range []string{"main.js", "main.py", "openclaw.plugin.json"} {
if fileExists(filepath.Join(path, flag)) {
return false
}
}
if hasOCExtensions(filepath.Join(path, "package.json")) {
return false
}
// 纯 skill有 SKILL.md 或 skill.json 即认
return fileExists(filepath.Join(path, SkillFileName)) ||
fileExists(filepath.Join(path, MetaFileName))
}
// hasOCExtensions 检查 package.json 是否带 openclaw 扩展声明(与 clawhubadapter 同语义)。
func hasOCExtensions(pkgPath string) bool {
data, err := os.ReadFile(pkgPath)
if err != nil {
return false
}
var pkg struct {
Openclaw json.RawMessage `json:"openclaw"`
}
return json.Unmarshal(data, &pkg) == nil && len(pkg.Openclaw) > 0
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
// loadOne 从路径加载单个 skill 并入表(幂等:已存在且未变更则跳过)。
func (p *Plugin) loadOne(path string) (*SkillEntry, error) {
sk, err := plugin.LoadSKILL(path)
if err != nil {
return nil, err
}
name := sk.Name()
if name == "" || name == "." {
return nil, fmt.Errorf("skill at %s has empty name", path)
}
p.mu.Lock()
defer p.mu.Unlock()
if old, ok := p.skills[name]; ok {
// 幂等:同路径同内容不重复加载
if old.path == path {
return old, nil
}
// 重名不同路径:拒绝并提示
p.mu.Unlock()
return nil, fmt.Errorf("skill name conflict: %q already loaded from %s", name, old.path)
}
entry := &SkillEntry{sk: sk, path: path, enabled: true}
p.skills[name] = entry
return entry, nil
}
// removeOne 从表中移除 skill不动磁盘
func (p *Plugin) removeOne(name string) bool {
p.mu.Lock()
defer p.mu.Unlock()
if _, ok := p.skills[name]; !ok {
return false
}
delete(p.skills, name)
return true
}
// snapshot 返回排序后的 skill 名列表。
func (p *Plugin) snapshot() []*SkillEntry {
p.mu.RLock()
defer p.mu.RUnlock()
names := make([]string, 0, len(p.skills))
for n := range p.skills {
names = append(names, n)
}
sort.Strings(names)
out := make([]*SkillEntry, 0, len(names))
for _, n := range names {
out = append(out, p.skills[n])
}
return out
}
func (p *Plugin) get(name string) (*SkillEntry, bool) {
p.mu.RLock()
defer p.mu.RUnlock()
e, ok := p.skills[name]
return e, ok
}
// SkillIndex 实现 agentCore.SkillIndexProvider返回已启用技能的精炼索引。
// 每次 LLM 调用都会执行,必须轻量(只读锁 + 字符串拼接,无 IO
func (p *Plugin) SkillIndex() string {
entries := p.snapshot()
var b strings.Builder
for _, e := range entries {
if !e.enabled {
continue
}
desc := e.sk.Description()
if desc == "" || desc == "---" {
desc = "(无描述)"
}
if len(desc) > 80 {
desc = desc[:80] + "..."
}
fmt.Fprintf(&b, "%s v%s - %s\n", e.sk.Name(), e.sk.Version(), desc)
}
return strings.TrimRight(b.String(), "\n")
}

View File

@ -0,0 +1,368 @@
package skillmgr
import (
"archive/tar"
"compress/gzip"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// ---- 测试辅助 ----
type nilSettings struct{}
func (m *nilSettings) Get(string) (interface{}, error) { return nil, nil }
func (m *nilSettings) Set(string, interface{}) error { return nil }
func (m *nilSettings) List(string) ([]string, error) { return nil, nil }
func (m *nilSettings) GetCore(string) (interface{}, error) { return nil, nil }
func (m *nilSettings) SetCore(string, interface{}) error { return nil }
func (m *nilSettings) ListCore(string) ([]string, error) { return nil, nil }
func (m *nilSettings) GetPlugin(string, string) (interface{}, error) { return nil, nil }
func (m *nilSettings) SetPlugin(string, string, interface{}) error { return nil }
func (m *nilSettings) ListPlugin(string, string) ([]string, error) { return nil, nil }
func (m *nilSettings) RegisterDef(pubsdk.ConfigDef) {}
func (m *nilSettings) Defs(string) []*pubsdk.ConfigDef { return nil }
func (m *nilSettings) Dump() map[string]interface{} { return nil }
func (m *nilSettings) Plugins() []string { return nil }
func (m *nilSettings) DefsCore(string) []*sdk.ConfigDef { return nil }
func (m *nilSettings) DefsPlugin(string, string) []*sdk.ConfigDef { return nil }
func (m *nilSettings) Remove(string) error { return nil }
func (m *nilSettings) RemoveCore(string) error { return nil }
func (m *nilSettings) RemovePlugin(string, string) error { return nil }
// toolSpy 记录注册的工具,并可按名调用 handler。
type toolSpy struct {
mu sync.Mutex
handlers map[string]func(args map[string]interface{}) (interface{}, error)
}
func newToolSpy() *toolSpy { return &toolSpy{handlers: map[string]func(map[string]interface{}) (interface{}, error){}} }
func (s *toolSpy) Register(name string, def interface{}, h func(args map[string]interface{}) (interface{}, error)) error {
s.mu.Lock()
defer s.mu.Unlock()
s.handlers[name] = h
return nil
}
func (s *toolSpy) call(t *testing.T, name string, args map[string]interface{}) (interface{}, error) {
t.Helper()
s.mu.Lock()
h, ok := s.handlers[name]
s.mu.Unlock()
if !ok {
t.Fatalf("tool %q not registered", name)
}
return h(args)
}
// newTestPlugin 构造带真实事件总线的 skillmgr 实例。
func newTestPlugin(t *testing.T) (*Plugin, *toolSpy, *events.Bus) {
t.Helper()
dir := t.TempDir()
bus := events.NewBus()
p := New("skillmgr", dir)
reg := func(name string, def interface{}, h func(args map[string]interface{}) (interface{}, error)) error {
_ = name
_ = def
_ = h
return nil
}
_ = reg // 占位避免误用
spy := newToolSpy()
cfgSDK := sdk.New("skillmgr", sdk.SDKConfig{
RegTool: func(name string, def sdk.ToolDef, h sdk.ToolHandler) error {
return spy.Register(name, def, func(args map[string]interface{}) (interface{}, error) {
return h(args)
})
},
Settings: &nilSettings{},
EventBus: bus,
})
if err := p.Start(cfgSDK); err != nil {
t.Fatalf("Start: %v", err)
}
t.Cleanup(func() { p.Stop() })
return p, spy, bus
}
func writeSkill(t *testing.T, dir, name, description string) string {
t.Helper()
sd := filepath.Join(dir, name)
os.MkdirAll(sd, 0755)
content := "---\nname: " + name + "\nversion: 1.0.0\n---\n\n# " + name + "\n\n" + description + "\n"
if err := os.WriteFile(filepath.Join(sd, SkillFileName), []byte(content), 0644); err != nil {
t.Fatal(err)
}
return sd
}
const sampleSkillContent = `---
name: demo-skill
version: 0.2.0
author: tester
---
# demo-skill
用于测试的演示技能。
## 使用时机
测试时使用。
## 操作步骤
### step-1
第一步说明。
- target: 目标参数
执行示例:
` + "```bash\necho demo\n```" + `
`
// ---- 用例 ----
func TestScanExistingLoadsPureSkillsOnly(t *testing.T) {
dir := t.TempDir()
writeSkill(t, dir, "alpha", "alpha desc")
writeSkill(t, dir, "beta", "beta desc")
// sidecar 目录main.js应被跳过
scDir := filepath.Join(dir, "sidecar-thing")
os.MkdirAll(scDir, 0755)
os.WriteFile(filepath.Join(scDir, "main.js"), []byte("//"), 0644)
// OC plugin 目录应被跳过
ocDir := filepath.Join(dir, "oc-thing")
os.MkdirAll(ocDir, 0755)
os.WriteFile(filepath.Join(ocDir, "openclaw.plugin.json"), []byte("{}"), 0644)
// 隐藏目录跳过
hd := filepath.Join(dir, ".hidden")
os.MkdirAll(hd, 0755)
os.WriteFile(filepath.Join(hd, SkillFileName), []byte("x"), 0644)
p := New("skillmgr", dir)
bus := events.NewBus()
sdkInst := sdk.New("skillmgr", sdk.SDKConfig{
Settings: &nilSettings{},
EventBus: bus,
})
if err := p.Start(sdkInst); err != nil {
t.Fatal(err)
}
defer p.Stop()
names := map[string]bool{}
for _, e := range p.snapshot() {
names[e.sk.Name()] = true
}
if !names["alpha"] || !names["beta"] {
t.Fatalf("expected alpha+beta loaded, got %v", names)
}
if names["sidecar-thing"] || names["oc-thing"] || names[".hidden"] {
t.Fatalf("sidecar/OC/hidden should not load, got %v", names)
}
}
func TestSkillDetectedEventHandoff(t *testing.T) {
p, _, bus := newTestPlugin(t)
// 模拟 clawhubadapter 移交事件
dir := p.skillsDir
sd := filepath.Join(dir, "handed")
os.MkdirAll(sd, 0755)
os.WriteFile(filepath.Join(sd, SkillFileName), []byte(sampleSkillContent), 0644)
bus.Publish(&events.Event{
Type: detectEvent,
Source: "clawhubadapter",
Payload: map[string]interface{}{"path": sd},
Timestamp: time.Now().Unix(),
})
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, ok := p.get("handed"); ok {
break
}
time.Sleep(10 * time.Millisecond)
}
if _, ok := p.get("handed"); !ok {
t.Fatal("handed-off skill not registered after event")
}
}
func TestCreateValidateLoadFlow(t *testing.T) {
p, spy, _ := newTestPlugin(t)
// 两步式:先骨架
out, err := spy.call(t, "skill_create", map[string]interface{}{
"name": "weather-notify",
"description": "天气通知",
})
if err != nil {
t.Fatalf("create skeleton: %v", err)
}
_ = out
if !fileExists(filepath.Join(p.skillsDir, "weather-notify", SkillFileName)) {
t.Fatal("skeleton SKILL.md missing")
}
// 第二步:完整内容覆盖 → 自动加载
if _, err := spy.call(t, "skill_create", map[string]interface{}{
"name": "weather-notify",
"content": sampleSkillContent,
}); err != nil {
t.Fatalf("create full: %v", err)
}
if _, ok := p.get("weather-notify"); !ok {
t.Fatal("full-content skill not loaded under its own name")
}
// 空白 content 走模板分支:生成骨架而非报错
if _, err := spy.call(t, "skill_create", map[string]interface{}{
"name": "bad-skill",
"description": "",
}); err != nil {
t.Fatalf("skeleton-only create should succeed: %v", err)
}
if !fileExists(filepath.Join(p.skillsDir, "bad-skill", SkillFileName)) {
t.Fatal("bad-skill skeleton missing")
}
// 有内容但缺描述的 content 被校验拒绝
if _, err := spy.call(t, "skill_create", map[string]interface{}{
"name": "no-desc",
"content": "---\nname: no-desc\n---\n\n## 步骤\n",
}); err == nil {
t.Fatal("content without description should be rejected by ValidateSKILLContent")
}
// 非法名称被拒
if _, err := spy.call(t, "skill_create", map[string]interface{}{"name": "../evil"}); err == nil {
t.Fatal("path traversal name should be rejected")
}
}
func TestExportInstallRoundTrip(t *testing.T) {
p, spy, _ := newTestPlugin(t)
// 先创建并加载一个 skill
if _, err := spy.call(t, "skill_create", map[string]interface{}{
"name": "roundtrip",
"content": strings.ReplaceAll(sampleSkillContent, "demo-skill", "roundtrip"),
}); err != nil {
t.Fatal(err)
}
outDir := filepath.Join(p.skillsDir, "..", "exports")
packPath := filepath.Join(outDir, "roundtrip.skm")
if _, err := spy.call(t, "skill_export", map[string]interface{}{"name": "roundtrip"}); err != nil {
t.Fatalf("export: %v", err)
}
if !fileExists(packPath) {
t.Fatalf("pack missing at %s", packPath)
}
// 卸载 + 删文件后从包重装
if _, err := spy.call(t, "skill_unload", map[string]interface{}{"name": "roundtrip", "delete_files": true}); err != nil {
t.Fatal(err)
}
if _, ok := p.get("roundtrip"); ok {
t.Fatal("skill should be unloaded")
}
if fileExists(filepath.Join(p.skillsDir, "roundtrip")) {
t.Fatal("files should be deleted")
}
if _, err := spy.call(t, "skill_install", map[string]interface{}{"source": packPath}); err != nil {
t.Fatalf("install: %v", err)
}
e, ok := p.get("roundtrip")
if !ok {
t.Fatal("reinstalled skill not loaded")
}
if e.sk.Version() != "0.2.0" {
t.Fatalf("unexpected version %q", e.sk.Version())
}
}
func TestEnableDisable(t *testing.T) {
p, spy, _ := newTestPlugin(t)
writeSkill(t, p.skillsDir, "toggle", "toggle desc")
p.scanExisting()
if _, err := spy.call(t, "skill_disable", map[string]interface{}{"name": "toggle"}); err != nil {
t.Fatal(err)
}
if e, _ := p.get("toggle"); e.enabled {
t.Fatal("should be disabled")
}
if _, err := spy.call(t, "skill_enable", map[string]interface{}{"name": "toggle"}); err != nil {
t.Fatal(err)
}
if e, _ := p.get("toggle"); !e.enabled {
t.Fatal("should be enabled")
}
}
func TestPackTarSlipRejected(t *testing.T) {
tmp := t.TempDir()
// 构造带 .. 逃逸条目的恶意包
packPath := filepath.Join(tmp, "evil.skm")
f, _ := os.Create(packPath)
gz := gzip.NewWriter(f)
tw := tar.NewWriter(gz)
hdr := &tar.Header{Name: "../../../evil.txt", Mode: 0644, Size: 3}
tw.WriteHeader(hdr)
tw.Write([]byte("bad"))
tw.Close()
gz.Close()
f.Close()
dst := filepath.Join(tmp, "out")
if _, err := unpackSkill(packPath, dst); err == nil {
t.Fatal("tar slip entry must be rejected")
}
}
func TestSkillIndex(t *testing.T) {
p, spy, _ := newTestPlugin(t)
if idx := p.SkillIndex(); idx != "" {
t.Fatalf("empty registry should give empty index, got %q", idx)
}
if _, err := spy.call(t, "skill_create", map[string]interface{}{
"name": "idx-test",
"content": strings.ReplaceAll(sampleSkillContent, "demo-skill", "idx-test"),
}); err != nil {
t.Fatal(err)
}
idx := p.SkillIndex()
if !strings.Contains(idx, "idx-test v0.2.0") || !strings.Contains(idx, "用于测试的演示技能。") {
t.Fatalf("index missing name/desc: %q", idx)
}
// 禁用后不出现在索引里
spy.call(t, "skill_disable", map[string]interface{}{"name": "idx-test"})
if idx := p.SkillIndex(); strings.Contains(idx, "idx-test") {
t.Fatalf("disabled skill should be excluded from index: %q", idx)
}
}

View File

@ -0,0 +1,415 @@
package skillmgr
import (
"fmt"
"os"
"path/filepath"
"strings"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
"gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// registerTools 注册 LLM 可调用的 skill 管理工具。
func (p *Plugin) registerTools() {
p.registerList()
p.registerInfo()
p.registerLoad()
p.registerUnload()
p.registerEnable()
p.registerDisable()
p.registerCreate()
p.registerExport()
p.registerInstall()
}
const tp = "skill_"
func (p *Plugin) registerList() {
p.sdk.RegisterTool(tp+"list", sdk.ToolDef{
Name: tp + "list",
Description: "列出所有已加载的原生技能native skill名称、版本、启用状态、描述。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
}, func(args map[string]interface{}) (interface{}, error) {
entries := p.snapshot()
if len(entries) == 0 {
return "当前没有已加载的原生技能。", nil
}
var b strings.Builder
fmt.Fprintf(&b, "原生技能 (%d):\n", len(entries))
for _, e := range entries {
state := "启用"
if !e.enabled {
state = "禁用"
}
fmt.Fprintf(&b, " %s v%s [%s] - %s\n",
e.sk.Name(), e.sk.Version(), state, e.sk.Description())
}
return b.String(), nil
})
}
func (p *Plugin) registerInfo() {
p.sdk.RegisterTool(tp+"info", sdk.ToolDef{
Name: tp + "info",
Description: "查看指定技能的详细信息与 SKILL.md 全文。执行技能前必读:按文档指示操作。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"name": map[string]interface{}{"type": "string", "description": "技能名称"},
},
"required": []string{"name"},
},
}, func(args map[string]interface{}) (interface{}, error) {
name, _ := args["name"].(string)
e, ok := p.get(name)
if !ok {
return nil, fmt.Errorf("skill %q not loaded; use skill_list to view available skills", name)
}
state := "启用"
if !e.enabled {
state = "禁用"
}
var b strings.Builder
fmt.Fprintf(&b, "名称: %s\n版本: %s\n状态: %s\n路径: %s\n描述: %s\n",
e.sk.Name(), e.sk.Version(), state, e.path, e.sk.Description())
if tools := e.sk.Tools(); len(tools) > 0 {
b.WriteString("文档中声明的操作步骤:\n")
for _, td := range tools {
fmt.Fprintf(&b, " - %s: %s\n", td.Name, td.Description)
}
}
fmt.Fprintf(&b, "\n--- SKILL.md ---\n%s", e.sk.RawContent())
return b.String(), nil
})
}
func (p *Plugin) registerLoad() {
p.sdk.RegisterTool(tp+"load", sdk.ToolDef{
Name: tp + "load",
Description: "从 skills 目录热加载指定技能(目录名或单 .md 文件名加载后立即可用。skill_create 填充完内容后调用此工具生效。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"name": map[string]interface{}{"type": "string", "description": "skills 目录下的条目名(不含路径)"},
},
"required": []string{"name"},
},
}, func(args map[string]interface{}) (interface{}, error) {
name, _ := args["name"].(string)
if strings.ContainsAny(name, "/\\") || strings.HasPrefix(name, ".") {
return nil, fmt.Errorf("invalid skill entry name: %q", name)
}
path := filepath.Join(p.skillsDir, name)
if _, err := os.Stat(path); err != nil {
return nil, fmt.Errorf("not found in skills dir: %s", path)
}
e, err := p.loadOne(path)
if err != nil {
return nil, err
}
return fmt.Sprintf("已加载技能 %s v%s", e.sk.Name(), e.sk.Version()), nil
})
}
func (p *Plugin) registerUnload() {
name := tp + "unload"
p.sdk.RegisterTool(name, sdk.ToolDef{
Name: name,
Description: "卸载指定技能(仅从内存移除;磁盘文件保留。需删除文件用参数 delete_files。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"name": map[string]interface{}{"type": "string", "description": "技能名称"},
"delete_files": map[string]interface{}{"type": "boolean", "description": "是否同时删除磁盘文件(默认 false"},
},
"required": []string{"name"},
},
}, func(args map[string]interface{}) (interface{}, error) {
name, _ := args["name"].(string)
delFiles, _ := args["delete_files"].(bool)
e, ok := p.get(name)
if !ok {
return nil, fmt.Errorf("skill %q not loaded", name)
}
path := e.path
if !p.removeOne(name) {
return nil, fmt.Errorf("skill %q not loaded", name)
}
msg := fmt.Sprintf("已卸载技能 %s内存", name)
if delFiles {
if err := os.RemoveAll(path); err != nil {
return msg + fmt.Sprintf(",但删除文件失败: %v", err), nil
}
msg += ",磁盘文件已删除"
}
return msg, nil
})
}
func (p *Plugin) registerEnable() { p.registerSetEnabled(true) }
func (p *Plugin) registerDisable() {
p.registerSetEnabled(false)
}
func (p *Plugin) registerSetEnabled(v bool) {
action := "disable"
word := "禁用"
if v {
action = "enable"
word = "启用"
}
name := tp + action
desc := word + "指定技能。禁用后 agent 不再使用该技能(保留在列表中标记为禁用)。"
p.sdk.RegisterTool(name, sdk.ToolDef{
Name: name,
Description: desc,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"name": map[string]interface{}{"type": "string", "description": "技能名称"},
},
"required": []string{"name"},
},
}, func(args map[string]interface{}) (interface{}, error) {
skillName, _ := args["name"].(string)
p.mu.Lock()
defer p.mu.Unlock()
e, ok := p.skills[skillName]
if !ok {
return nil, fmt.Errorf("skill %q not loaded", skillName)
}
e.enabled = v
e.sk.SetEnabled(v)
return fmt.Sprintf("已%s技能 %s", word, skillName), nil
})
}
func (p *Plugin) registerCreate() {
name := tp + "create"
p.sdk.RegisterTool(name, sdk.ToolDef{
Name: name,
Description: "创建新技能骨架:生成标准 SKILL.md 模板写入 skills/<name>/。创建后在 content 参数填入完整文档内容并再次调用以落盘生效(两步式),或直接传 content 一步完成。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"name": map[string]interface{}{"type": "string", "description": "技能名(小写字母/数字/连字符,如 weather-notify"},
"description": map[string]interface{}{"type": "string", "description": "一句话描述技能用途(模板模式的 frontmatter description"},
"content": map[string]interface{}{"type": "string", "description": "可选。完整 SKILL.md 内容;提供时直接校验并写入,否则生成骨架模板"},
"scripts": map[string]interface{}{"type": "boolean", "description": "模板模式是否创建 scripts/ 目录占位(默认 true"},
},
"required": []string{"name"},
},
}, func(args map[string]interface{}) (interface{}, error) {
name, _ := args["name"].(string)
description, _ := args["description"].(string)
content, _ := args["content"].(string)
withScripts := true
if ws, ok := args["scripts"].(bool); ok {
withScripts = ws
}
if err := validateSkillName(name); err != nil {
return nil, err
}
dir := filepath.Join(p.skillsDir, name)
// 两步式第二步 / 一步式:提供 content 则校验后直接写入
if strings.TrimSpace(content) != "" {
if err := plugin.ValidateSKILLContent(content); err != nil {
return nil, fmt.Errorf("content 校验失败: %w", err)
}
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, err
}
if err := os.WriteFile(filepath.Join(dir, SkillFileName), []byte(content), 0644); err != nil {
return nil, err
}
e, err := p.loadOne(dir)
if err != nil {
return nil, err
}
return fmt.Sprintf("技能 %s 已写入并加载生效v%s。用 skill_info 可查看全文。", e.sk.Name(), e.sk.Version()), nil
}
// 模板模式:生成骨架
if fileExists(filepath.Join(dir, SkillFileName)) {
return nil, fmt.Errorf("skill %s already exists at %s; use a different name or edit the file directly", name, dir)
}
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, err
}
if withScripts {
os.MkdirAll(filepath.Join(dir, "scripts"), 0755)
}
tpl := renderTemplate(name, description)
if err := os.WriteFile(filepath.Join(dir, SkillFileName), []byte(tpl), 0644); err != nil {
return nil, err
}
return fmt.Sprintf(
"骨架已生成: %s\n下一步读取该文件按实际用途补全正文与操作步骤## 步骤小节会被解析为技能动作说明),\n然后调用 skill_create 并传入完整 content 覆盖写入,最后自动加载生效。",
filepath.Join(dir, SkillFileName)), nil
})
}
func (p *Plugin) registerExport() {
p.sdk.RegisterTool(tp+"export", sdk.ToolDef{
Name: tp + "export",
Description: "导出指定技能为 .skm 分发包tar.gz 格式),输出到 output 路径或默认 data/exports/ 下。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"name": map[string]interface{}{"type": "string", "description": "技能名称"},
"output": map[string]interface{}{"type": "string", "description": "输出 .skm 文件完整路径(可选,默认 data/exports/<name>.skm"},
},
"required": []string{"name"},
},
}, func(args map[string]interface{}) (interface{}, error) {
name, _ := args["name"].(string)
output, _ := args["output"].(string)
e, ok := p.get(name)
if !ok {
return nil, fmt.Errorf("skill %q not loaded; use skill_list first", name)
}
outPath := output
if outPath == "" {
exportsDir := filepath.Join(p.skillsDir, "..", "exports")
os.MkdirAll(exportsDir, 0755)
outPath = filepath.Join(exportsDir, name+PackExt)
}
n, err := packSkill(e.path, outPath)
if err != nil {
return nil, err
}
return fmt.Sprintf("已导出 %d 个文件到 %s", n, outPath), nil
})
}
func (p *Plugin) registerInstall() {
p.sdk.RegisterTool(tp+"install", sdk.ToolDef{
Name: tp + "install",
Description: "安装技能包:支持 .skm 包路径或 local:<skills目录路径> 本地目录。安装后立即加载生效。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"source": map[string]interface{}{"type": "string", "description": "安装来源:<path>.skm 或 local:<dir>"},
},
"required": []string{"source"},
},
}, func(args map[string]interface{}) (interface{}, error) {
source, _ := args["source"].(string)
source = strings.TrimSpace(source)
switch {
case strings.HasPrefix(source, "local:"):
dir := strings.TrimPrefix(source, "local:")
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
return nil, fmt.Errorf("local dir not found: %s", dir)
}
dstName := filepath.Base(dir)
if err := validateSkillName(dstName); err != nil {
return nil, err
}
dst := filepath.Join(p.skillsDir, dstName)
if _, err := os.Stat(dst); err == nil {
return nil, fmt.Errorf("skill dir already exists: %s", dst)
}
if err := copyDir(dir, dst); err != nil {
return nil, fmt.Errorf("copy failed: %w", err)
}
e, err := p.loadOne(dst)
if err != nil {
os.RemoveAll(dst)
return nil, fmt.Errorf("installed but load failed: %w", err)
}
return fmt.Sprintf("已从本地目录安装技能 %s v%s", e.sk.Name(), e.sk.Version()), nil
case strings.HasSuffix(strings.ToLower(source), PackExt):
if _, err := os.Stat(source); err != nil {
return nil, fmt.Errorf("pack not found: %s", source)
}
dstName := strings.TrimSuffix(filepath.Base(source), PackExt)
if err := validateSkillName(dstName); err != nil {
return nil, err
}
dst := filepath.Join(p.skillsDir, dstName)
if _, err := os.Stat(dst); err == nil {
return nil, fmt.Errorf("skill dir already exists: %s", dst)
}
n, err := unpackSkill(source, dst)
if err != nil {
os.RemoveAll(dst)
return nil, fmt.Errorf("unpack failed: %w", err)
}
e, err := p.loadOne(dst)
if err != nil {
os.RemoveAll(dst)
return nil, fmt.Errorf("unpacked %d files but load failed: %w", n, err)
}
return fmt.Sprintf("已安装 %d 个文件,技能 %s v%s 生效", n, e.sk.Name(), e.sk.Version()), nil
default:
return nil, fmt.Errorf("unsupported source: %q (use <path>.skm or local:<dir>)", source)
}
})
}
// validateSkillName 校验技能名:小写字母/数字/连字符1-64 字符。
func validateSkillName(name string) error {
if name == "" || len(name) > 64 {
return fmt.Errorf("invalid skill name length")
}
for _, r := range name {
ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.'
if !ok {
return fmt.Errorf("invalid character %q in skill name %q (allow lowercase/digits/-/_/.)", r, name)
}
}
if strings.HasPrefix(name, ".") {
return fmt.Errorf("skill name cannot start with dot")
}
return nil
}
// renderTemplate 生成 SKILL.md 骨架。
func renderTemplate(name, description string) string {
if description == "" {
description = "(TODO: 一句话描述本技能的用途)"
}
return fmt.Sprintf(`---
name: %s
version: 0.1.0
author: homeagent
---
# %s
%s
## 使用时机
(TODO: 描述什么场景下 agent 应当使用本技能)
## 操作步骤
### step-1
(TODO: 第一步做什么。首行非标题文本会作为该步骤的描述)
- param1: 参数1说明
- param2: 参数2说明
执行示例:
`+"```"+`bash
echo "hello from %s"
`+"```"+`
## 注意事项
- (TODO: 安全提示、失败重试策略等)
`, name, name, description, name)
}