mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 18:08:04 +00:00
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:
266
internal/plugins/skillmgr/pack.go
Normal file
266
internal/plugins/skillmgr/pack.go
Normal file
@ -0,0 +1,266 @@
|
||||
package skillmgr
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// packSkill 把 skill 目录打包为 .skm(tar.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
|
||||
})
|
||||
}
|
||||
268
internal/plugins/skillmgr/plugin.go
Normal file
268
internal/plugins/skillmgr/plugin.go
Normal file
@ -0,0 +1,268 @@
|
||||
// Package skillmgr 实现原生 SKILL 插件管理器。
|
||||
//
|
||||
// 职责边界:
|
||||
// - 本插件是 native skill(SKILL.md / skill.json 指令文档型插件)的唯一归属者,
|
||||
// 负责其全生命周期:加载、卸载、启停、生成(模板)、导出(.skm 包)、安装。
|
||||
// - clawhubadapter(OpenClaw 兼容层)扫描 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.py;OC 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")
|
||||
}
|
||||
368
internal/plugins/skillmgr/skillmgr_test.go
Normal file
368
internal/plugins/skillmgr/skillmgr_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
415
internal/plugins/skillmgr/tools.go
Normal file
415
internal/plugins/skillmgr/tools.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user