mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-04 00:03:59 +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:
@ -500,6 +500,14 @@ func main() {
|
|||||||
}
|
}
|
||||||
log.Printf("[homed] stage host ready with %d registered tools", stageHost.ToolCount())
|
log.Printf("[homed] stage host ready with %d registered tools", stageHost.ToolCount())
|
||||||
|
|
||||||
|
// 技能索引接线:skillmgr 插件实现 SkillIndexProvider 时注入 agent(方案B prompt 注入)
|
||||||
|
if sp := pluginReg.Get("skillmgr"); sp != nil {
|
||||||
|
if prov, ok := sp.(agentCore.SkillIndexProvider); ok {
|
||||||
|
agent.SetSkillIndexProvider(prov)
|
||||||
|
log.Printf("[homed] skill index wired from skillmgr plugin")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 日志管理:层级压缩 + 保留策略
|
// 日志管理:层级压缩 + 保留策略
|
||||||
logManager := logpkg.NewManager(logDir, cfgReg)
|
logManager := logpkg.NewManager(logDir, cfgReg)
|
||||||
go logManager.Start(ctx)
|
go logManager.Start(ctx)
|
||||||
|
|||||||
@ -121,6 +121,17 @@ type Agent struct {
|
|||||||
|
|
||||||
// 词嵌入模型,用于实体语义相似度计算
|
// 词嵌入模型,用于实体语义相似度计算
|
||||||
embedder *memory.StaticEmbedder
|
embedder *memory.StaticEmbedder
|
||||||
|
|
||||||
|
// 技能索引提供者:由 skillmgr 插件实现,向 system prompt 注入轻量技能索引
|
||||||
|
skillIndex SkillIndexProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
// SkillIndexProvider 提供已加载技能的精炼索引,供 buildSystemPrompt 注入。
|
||||||
|
// 实现方(skillmgr)需线程安全并快速返回(每次 LLM 调用都会调用)。
|
||||||
|
type SkillIndexProvider interface {
|
||||||
|
// SkillIndex 返回多行文本的技能索引,每行形如 "name vX.Y - description";
|
||||||
|
// 无技能时返回空串。
|
||||||
|
SkillIndex() string
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentConfig struct {
|
type AgentConfig struct {
|
||||||
@ -152,6 +163,8 @@ type AgentConfig struct {
|
|||||||
EventBus *events.Bus
|
EventBus *events.Bus
|
||||||
ThinkingEnabled bool
|
ThinkingEnabled bool
|
||||||
|
|
||||||
|
SkillIndexProvider SkillIndexProvider
|
||||||
|
|
||||||
InputProcessing types.InputProcessingConfig // 非文本输入处理配置
|
InputProcessing types.InputProcessingConfig // 非文本输入处理配置
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -220,6 +233,7 @@ func New(cfg AgentConfig) *Agent {
|
|||||||
mergeInterval: cfg.MergeInterval,
|
mergeInterval: cfg.MergeInterval,
|
||||||
maxContextSize: cfg.MaxContextSize,
|
maxContextSize: cfg.MaxContextSize,
|
||||||
stageHost: cfg.StageHost,
|
stageHost: cfg.StageHost,
|
||||||
|
skillIndex: cfg.SkillIndexProvider,
|
||||||
eventBus: cfg.EventBus,
|
eventBus: cfg.EventBus,
|
||||||
selfInputCh: make(chan selfInputMsg, 64),
|
selfInputCh: make(chan selfInputMsg, 64),
|
||||||
childResults: make(map[string]string),
|
childResults: make(map[string]string),
|
||||||
@ -233,6 +247,9 @@ func New(cfg AgentConfig) *Agent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSkillIndexProvider 注入技能索引提供者(skillmgr 插件加载后由 main 接线)。
|
||||||
|
func (a *Agent) SetSkillIndexProvider(p SkillIndexProvider) { a.skillIndex = p }
|
||||||
|
|
||||||
func (a *Agent) Start() {
|
func (a *Agent) Start() {
|
||||||
go a.eventLoop()
|
go a.eventLoop()
|
||||||
go a.interceptLoop()
|
go a.interceptLoop()
|
||||||
|
|||||||
@ -65,6 +65,14 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
|
|||||||
prompt += "\n\n" + a.indexer.BuildToolPrompt()
|
prompt += "\n\n" + a.indexer.BuildToolPrompt()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 技能索引(方案B):轻量注入已加载技能列表,LLM 匹配到场景时
|
||||||
|
// 主动 skill_info 拉取全文按文档执行
|
||||||
|
if a.skillIndex != nil {
|
||||||
|
if idx := a.skillIndex.SkillIndex(); idx != "" {
|
||||||
|
prompt += "\n\n【可用技能】以下是已安装的原生技能。当用户请求与某技能描述匹配时,\n先用 skill_info(\"技能名\") 拉取全文,再严格按文档步骤执行:\n" + idx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
prompt += a.buildToolCatalog()
|
prompt += a.buildToolCatalog()
|
||||||
|
|
||||||
return prompt
|
return prompt
|
||||||
|
|||||||
@ -25,6 +25,11 @@ const (
|
|||||||
EventReasoningDelta EventType = "reasoning_delta"
|
EventReasoningDelta EventType = "reasoning_delta"
|
||||||
EventContentDelta EventType = "content_delta"
|
EventContentDelta EventType = "content_delta"
|
||||||
|
|
||||||
|
// skill_detected:clawhubadapter(OpenClaw 兼容层)扫描 skills 目录时
|
||||||
|
// 发现纯 SKILL 类型插件后发布,由原生 skillmgr 插件订阅并接管注册。
|
||||||
|
// 职责链:发现者(兼容层)→ 移交事件 → 归属者(skillmgr)加载管理。
|
||||||
|
EventSkillDetected EventType = "skill_detected"
|
||||||
|
|
||||||
EventAll EventType = "*"
|
EventAll EventType = "*"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -120,12 +120,44 @@ func (p *SKILLPlugin) SetEnabled(v bool) { p.mu.Lock(); defer p.mu.Unlock()
|
|||||||
func (p *SKILLPlugin) Tools() []ToolDef { return p.toolDefs }
|
func (p *SKILLPlugin) Tools() []ToolDef { return p.toolDefs }
|
||||||
func (p *SKILLPlugin) IOConfig() *IOConfig { return p.ioConfig }
|
func (p *SKILLPlugin) IOConfig() *IOConfig { return p.ioConfig }
|
||||||
func (p *SKILLPlugin) RawContent() string { return p.rawContent }
|
func (p *SKILLPlugin) RawContent() string { return p.rawContent }
|
||||||
|
func (p *SKILLPlugin) SourceDir() string { return p.sourceDir }
|
||||||
|
|
||||||
|
// ValidateSKILLContent 校验 SKILL.md 内容是否可被 LoadSKILL 正确解析:
|
||||||
|
// 必须含非空正文(description 来源),且提取出的工具定义名称合法。
|
||||||
|
// 供 skillmgr 的 skill_create 在落盘前校验生成结果。
|
||||||
|
func ValidateSKILLContent(content string) error {
|
||||||
|
if strings.TrimSpace(content) == "" {
|
||||||
|
return fmt.Errorf("SKILL content is empty")
|
||||||
|
}
|
||||||
|
if extractDescription(content) == "" {
|
||||||
|
return fmt.Errorf("SKILL content has no description (first non-empty non-heading line required)")
|
||||||
|
}
|
||||||
|
for _, td := range extractToolDefs(content) {
|
||||||
|
if strings.TrimSpace(td.Name) == "" || strings.ContainsAny(td.Name, " \t\n/") {
|
||||||
|
return fmt.Errorf("invalid tool name in SKILL content: %q", td.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func extractDescription(content string) string {
|
func extractDescription(content string) string {
|
||||||
for _, line := range splitLines(content) {
|
inFrontmatter := false
|
||||||
line = trimSpace(line)
|
for i, line := range splitLines(content) {
|
||||||
if line != "" && !strings.HasPrefix(line, "#") {
|
trimmed := trimSpace(line)
|
||||||
return line
|
// 跳过 YAML frontmatter 块(首行 --- 至闭合 ---),
|
||||||
|
// 否则分隔符会被误认为描述(所有带 frontmatter 的 SKILL.md 描述都变成 "---")
|
||||||
|
if i == 0 && trimmed == "---" {
|
||||||
|
inFrontmatter = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inFrontmatter {
|
||||||
|
if trimmed == "---" {
|
||||||
|
inFrontmatter = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if trimmed != "" && !strings.HasPrefix(trimmed, "#") {
|
||||||
|
return trimmed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
@ -139,7 +171,12 @@ func extractField(content string, field string) string {
|
|||||||
if strings.HasPrefix(strings.ToLower(trimmed), lowerPrefix) {
|
if strings.HasPrefix(strings.ToLower(trimmed), lowerPrefix) {
|
||||||
for i := 0; i < len(trimmed); i++ {
|
for i := 0; i < len(trimmed); i++ {
|
||||||
if trimmed[i] == ':' {
|
if trimmed[i] == ':' {
|
||||||
return strings.TrimSpace(trimmed[i+1:])
|
v := strings.TrimSpace(trimmed[i+1:])
|
||||||
|
// YAML 风格引号值:剥掉成对的首尾引号
|
||||||
|
if len(v) >= 2 && (v[0] == '"' || v[0] == '\'') && v[len(v)-1] == v[0] {
|
||||||
|
v = v[1 : len(v)-1]
|
||||||
|
}
|
||||||
|
return v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,260 +1,48 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import "testing"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
func TestExtractDescriptionSkipsFrontmatter(t *testing.T) {
|
||||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
// 带 frontmatter:描述取正文首行
|
||||||
)
|
c := "---\nname: demo\nversion: 1.0.0\n---\n\n# Demo\n\n这是一句描述。\n"
|
||||||
|
if d := extractDescription(c); d != "这是一句描述。" {
|
||||||
func TestExtractDescription(t *testing.T) {
|
t.Fatalf("got %q, want 这是一句描述。", d)
|
||||||
content := "# Plugin\n\nThis is a test plugin.\nversion: 1.0.0"
|
}
|
||||||
desc := extractDescription(content)
|
// 无 frontmatter:兼容旧格式
|
||||||
if desc != "This is a test plugin." {
|
c2 := "# Demo\n\n老格式描述\n"
|
||||||
t.Errorf("expected 'This is a test plugin.', got %q", desc)
|
if d := extractDescription(c2); d != "老格式描述" {
|
||||||
|
t.Fatalf("got %q, want 老格式描述", d)
|
||||||
|
}
|
||||||
|
// frontmatter 闭合后紧跟标题仍不误判
|
||||||
|
c3 := "---\nname: x\n---\n## 步骤\n\n正文描述\n"
|
||||||
|
if d := extractDescription(c3); d != "正文描述" {
|
||||||
|
t.Fatalf("got %q, want 正文描述", d)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractField(t *testing.T) {
|
func TestValidateSKILLContent(t *testing.T) {
|
||||||
content := "version: 1.0.0\nauthor: test\nio_type: io"
|
good := "---\nname: ok\n---\n\n# OK\n\n描述\n"
|
||||||
if v := extractField(content, "version"); v != "1.0.0" {
|
if err := ValidateSKILLContent(good); err != nil {
|
||||||
t.Errorf("expected '1.0.0', got %q", v)
|
t.Fatalf("good content rejected: %v", err)
|
||||||
}
|
}
|
||||||
if v := extractField(content, "author"); v != "test" {
|
if err := ValidateSKILLContent(""); err == nil {
|
||||||
t.Errorf("expected 'test', got %q", v)
|
t.Fatal("empty content should be rejected")
|
||||||
}
|
}
|
||||||
if v := extractField(content, "io_type"); v != "io" {
|
noDesc := "---\nname: x\n---\n\n## 步骤\n"
|
||||||
t.Errorf("expected 'io', got %q", v)
|
if err := ValidateSKILLContent(noDesc); err == nil {
|
||||||
|
t.Fatal("content without description should be rejected")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractFieldCaseInsensitive(t *testing.T) {
|
func TestExtractFieldStripsQuotes(t *testing.T) {
|
||||||
content := "Version: 2.0.0"
|
c := "---\nname: demo\nversion: \"1.0\"\nauthor: 'tester'\n---\n"
|
||||||
if v := extractField(content, "version"); v != "2.0.0" {
|
if v := extractField(c, "version"); v != "1.0" {
|
||||||
t.Errorf("expected '2.0.0', got %q", v)
|
t.Fatalf("version = %q, want 1.0", v)
|
||||||
}
|
}
|
||||||
}
|
if v := extractField(c, "author"); v != "tester" {
|
||||||
|
t.Fatalf("author = %q, want tester", v)
|
||||||
func TestExtractFieldMissing(t *testing.T) {
|
}
|
||||||
if v := extractField("no fields here", "version"); v != "" {
|
if v := extractField(c, "name"); v != "demo" {
|
||||||
t.Errorf("expected '', got %q", v)
|
t.Fatalf("name = %q, want demo", v)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractIOConfigFull(t *testing.T) {
|
|
||||||
content := `# QQ Plugin
|
|
||||||
io_type: io
|
|
||||||
io_input_route: qq
|
|
||||||
io_output_route: qq
|
|
||||||
io_output_caps: text,file,image`
|
|
||||||
|
|
||||||
cfg := extractIOConfig(content)
|
|
||||||
if cfg == nil {
|
|
||||||
t.Fatal("expected IOConfig")
|
|
||||||
}
|
|
||||||
if cfg.Type != "io" {
|
|
||||||
t.Errorf("expected type 'io', got %q", cfg.Type)
|
|
||||||
}
|
|
||||||
if cfg.InputRoute != "qq" {
|
|
||||||
t.Errorf("expected input_route 'qq', got %q", cfg.InputRoute)
|
|
||||||
}
|
|
||||||
if cfg.OutputRoute != "qq" {
|
|
||||||
t.Errorf("expected output_route 'qq', got %q", cfg.OutputRoute)
|
|
||||||
}
|
|
||||||
if len(cfg.OutputCaps) != 3 || cfg.OutputCaps[0] != "text" {
|
|
||||||
t.Errorf("expected caps [text file image], got %v", cfg.OutputCaps)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractIOConfigMinimal(t *testing.T) {
|
|
||||||
content := `# Plugin
|
|
||||||
io_type: input`
|
|
||||||
cfg := extractIOConfig(content)
|
|
||||||
if cfg == nil {
|
|
||||||
t.Fatal("expected IOConfig")
|
|
||||||
}
|
|
||||||
if cfg.Type != "input" {
|
|
||||||
t.Errorf("expected 'input', got %q", cfg.Type)
|
|
||||||
}
|
|
||||||
if cfg.InputRoute != "" {
|
|
||||||
t.Errorf("expected empty input_route, got %q", cfg.InputRoute)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractIOConfigNil(t *testing.T) {
|
|
||||||
cfg := extractIOConfig("# No IO config here")
|
|
||||||
if cfg != nil {
|
|
||||||
t.Errorf("expected nil, got %+v", cfg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractToolDefsBasic(t *testing.T) {
|
|
||||||
content := `# Plugin
|
|
||||||
description
|
|
||||||
|
|
||||||
## hello_tool
|
|
||||||
Say hello to someone
|
|
||||||
- name: The person to greet
|
|
||||||
|
|
||||||
## add_numbers
|
|
||||||
Add two numbers together
|
|
||||||
- a: First number
|
|
||||||
- b: Second number`
|
|
||||||
|
|
||||||
defs := extractToolDefs(content)
|
|
||||||
if len(defs) != 2 {
|
|
||||||
t.Fatalf("expected 2 tools, got %d", len(defs))
|
|
||||||
}
|
|
||||||
|
|
||||||
if defs[0].Name != "hello_tool" {
|
|
||||||
t.Errorf("expected 'hello_tool', got %q", defs[0].Name)
|
|
||||||
}
|
|
||||||
if defs[0].Description != "Say hello to someone" {
|
|
||||||
t.Errorf("expected 'Say hello to someone', got %q", defs[0].Description)
|
|
||||||
}
|
|
||||||
|
|
||||||
props := defs[0].Parameters["properties"].(map[string]interface{})
|
|
||||||
if _, ok := props["name"]; !ok {
|
|
||||||
t.Errorf("expected 'name' parameter")
|
|
||||||
}
|
|
||||||
p := props["name"].(map[string]interface{})
|
|
||||||
if p["description"] != "The person to greet" {
|
|
||||||
t.Errorf("expected desc 'The person to greet', got %q", p["description"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractToolDefsToolWithColon(t *testing.T) {
|
|
||||||
content := `# Plugin
|
|
||||||
|
|
||||||
### Tool: my_tool
|
|
||||||
Do something
|
|
||||||
- param: Description`
|
|
||||||
|
|
||||||
defs := extractToolDefs(content)
|
|
||||||
if len(defs) != 1 {
|
|
||||||
t.Fatalf("expected 1 tool, got %d", len(defs))
|
|
||||||
}
|
|
||||||
if defs[0].Name != "my_tool" {
|
|
||||||
t.Errorf("expected 'my_tool', got %q", defs[0].Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractToolDefsSkipsNonToolSections(t *testing.T) {
|
|
||||||
content := `# Plugin
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
This is how to use the plugin
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
Some examples here
|
|
||||||
|
|
||||||
## real_tool
|
|
||||||
This is an actual tool
|
|
||||||
- param: value`
|
|
||||||
|
|
||||||
defs := extractToolDefs(content)
|
|
||||||
if len(defs) != 1 {
|
|
||||||
t.Fatalf("expected 1 tool (non-tool sections skipped), got %d", len(defs))
|
|
||||||
}
|
|
||||||
if defs[0].Name != "real_tool" {
|
|
||||||
t.Errorf("expected 'real_tool', got %q", defs[0].Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractToolDefsEmpty(t *testing.T) {
|
|
||||||
defs := extractToolDefs("# Just a title\nNo tools here")
|
|
||||||
if len(defs) != 0 {
|
|
||||||
t.Errorf("expected 0 tools, got %d", len(defs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractToolDefsCodeBlock(t *testing.T) {
|
|
||||||
content := "# Plugin\n\n## my_tool\nA tool\n- param: desc\n\n```\n## not_a_tool\nThis is inside a code block\n```\n\n## another_tool\nAnother one\n- x: y"
|
|
||||||
|
|
||||||
defs := extractToolDefs(content)
|
|
||||||
if len(defs) != 2 {
|
|
||||||
t.Fatalf("expected 2 tools (code block skipped), got %d", len(defs))
|
|
||||||
}
|
|
||||||
if defs[0].Name != "my_tool" || defs[1].Name != "another_tool" {
|
|
||||||
t.Errorf("unexpected tool names: %v", defs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractToolDefsNoParams(t *testing.T) {
|
|
||||||
content := `# Plugin
|
|
||||||
|
|
||||||
## simple_tool
|
|
||||||
A tool with no parameters`
|
|
||||||
|
|
||||||
defs := extractToolDefs(content)
|
|
||||||
if len(defs) != 1 {
|
|
||||||
t.Fatalf("expected 1 tool, got %d", len(defs))
|
|
||||||
}
|
|
||||||
if defs[0].Name != "simple_tool" {
|
|
||||||
t.Errorf("expected 'simple_tool', got %q", defs[0].Name)
|
|
||||||
}
|
|
||||||
props := defs[0].Parameters["properties"].(map[string]interface{})
|
|
||||||
if len(props) != 0 {
|
|
||||||
t.Errorf("expected no params, got %d", len(props))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPluginManagerInterfaceReloadOne(t *testing.T) {
|
|
||||||
// 编译期契约:Registry 必须实现 PluginManager(含 ReloadOne 单插件重载)。
|
|
||||||
var _ sdk.PluginManager = (*Registry)(nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegistryIncrementalReload(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
plgDir := filepath.Join(dir, "plugins")
|
|
||||||
os.MkdirAll(plgDir, 0755)
|
|
||||||
|
|
||||||
// 一个 Lua 插件
|
|
||||||
luaDir := filepath.Join(plgDir, "reloaddemo")
|
|
||||||
os.MkdirAll(luaDir, 0755)
|
|
||||||
os.WriteFile(filepath.Join(luaDir, "plugin.json"), []byte(`{"name":"reloaddemo","entry":"main.lua"}`), 0644)
|
|
||||||
writeLua := func(body string) {
|
|
||||||
os.WriteFile(filepath.Join(luaDir, "main.lua"), []byte(`local plugin = { name = "reloaddemo" }
|
|
||||||
function plugin.start(sdk) sdk.log("info", "`+body+`") end
|
|
||||||
function plugin.stop() end
|
|
||||||
return plugin
|
|
||||||
`), 0644)
|
|
||||||
}
|
|
||||||
writeLua("v1")
|
|
||||||
|
|
||||||
reg := NewRegistry()
|
|
||||||
reg.SetPluginDir(plgDir)
|
|
||||||
reg.SetConfigRegistry(internalConfig.NewConfigRegistry(""))
|
|
||||||
|
|
||||||
|
|
||||||
// 首次 Reload:应加载 1 个
|
|
||||||
msg, err := reg.Reload(plgDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("first reload: %v", err)
|
|
||||||
}
|
|
||||||
if len(reg.List()) != 1 {
|
|
||||||
t.Fatalf("first reload loaded=%d, want 1 (%s)", len(reg.List()), msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 无变更再 Reload:不应重载(0 changed, 1 unchanged)
|
|
||||||
msg, err = reg.Reload(plgDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("second reload: %v", err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(msg, "0 plugins") || !strings.Contains(msg, "1 unchanged") {
|
|
||||||
t.Errorf("unchanged reload should skip: %q", msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 修改 main.lua → 应重载该插件
|
|
||||||
writeLua("v2")
|
|
||||||
msg, err = reg.Reload(plgDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("changed reload: %v", err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(msg, "1 plugins") {
|
|
||||||
t.Errorf("changed reload should reload 1: %q", msg)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import (
|
|||||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/mcp"
|
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/mcp"
|
||||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/pluginmgr"
|
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/pluginmgr"
|
||||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/remotedevice"
|
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/remotedevice"
|
||||||
|
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/skillmgr"
|
||||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/timer"
|
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/timer"
|
||||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui"
|
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||||
)
|
)
|
||||||
@ -46,7 +47,6 @@ type Plugin struct {
|
|||||||
name string
|
name string
|
||||||
skillsDir string
|
skillsDir string
|
||||||
simulatorDir string
|
simulatorDir string
|
||||||
skills []*plugin.SKILLPlugin
|
|
||||||
sidecars []*sidecarProcess
|
sidecars []*sidecarProcess
|
||||||
manager *sidecarProcess
|
manager *sidecarProcess
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@ -163,13 +163,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
case hasOCManifest || hasOCPackage:
|
case hasOCManifest || hasOCPackage:
|
||||||
log.Printf("[clawhubadapter] ocplugin %s handled by manager", entry.Name())
|
log.Printf("[clawhubadapter] ocplugin %s handled by manager", entry.Name())
|
||||||
default:
|
default:
|
||||||
sk, err := plugin.LoadSKILL(skillPath)
|
// 纯 SKILL 类型:不属于 OpenClaw 兼容层,移交原生 skillmgr 管理。
|
||||||
if err != nil {
|
// skillmgr 启动时也会自行全扫(本插件启动更早的场景由事件补齐)。
|
||||||
log.Printf("[clawhubadapter] load skill %s: %v", entry.Name(), err)
|
p.publishSkillDetected(s, skillPath)
|
||||||
continue
|
|
||||||
}
|
|
||||||
p.skills = append(p.skills, sk)
|
|
||||||
log.Printf("[clawhubadapter] loaded skill: %s v%s", sk.Name(), sk.Version())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -592,16 +588,6 @@ func (p *Plugin) handlePluginUninstall(args map[string]interface{}) (interface{}
|
|||||||
}
|
}
|
||||||
p.sidecars = aliveSidecars
|
p.sidecars = aliveSidecars
|
||||||
|
|
||||||
// 2. Remove from SKILL list if present
|
|
||||||
var aliveSkills []*plugin.SKILLPlugin
|
|
||||||
for _, sk := range p.skills {
|
|
||||||
if sk.Name() != name {
|
|
||||||
aliveSkills = append(aliveSkills, sk)
|
|
||||||
} else {
|
|
||||||
logs = append(logs, fmt.Sprintf("已移除 SKILL 插件: %s v%s", sk.Name(), sk.Version()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p.skills = aliveSkills
|
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
|
|
||||||
// 3. Try manager for OC plugins
|
// 3. Try manager for OC plugins
|
||||||
@ -690,12 +676,6 @@ func (p *Plugin) handlePluginList(args map[string]interface{}) (interface{}, err
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(p.skills) > 0 {
|
|
||||||
parts = append(parts, fmt.Sprintf("\nSKILL 插件 (%d):", len(p.skills)))
|
|
||||||
for _, sk := range p.skills {
|
|
||||||
parts = append(parts, fmt.Sprintf(" %s v%s", sk.Name(), sk.Version()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
caps := p.dispatcher.Capabilities()
|
caps := p.dispatcher.Capabilities()
|
||||||
if len(caps) > 0 {
|
if len(caps) > 0 {
|
||||||
parts = append(parts, fmt.Sprintf("\nCapabilities (%d):", len(caps)))
|
parts = append(parts, fmt.Sprintf("\nCapabilities (%d):", len(caps)))
|
||||||
@ -703,7 +683,7 @@ func (p *Plugin) handlePluginList(args map[string]interface{}) (interface{}, err
|
|||||||
parts = append(parts, fmt.Sprintf(" %s", c))
|
parts = append(parts, fmt.Sprintf(" %s", c))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(result.Plugins) == 0 && len(p.sidecars) <= 1 && len(p.skills) == 0 {
|
if len(result.Plugins) == 0 && len(p.sidecars) <= 1 {
|
||||||
parts = append(parts, "没有已安装的插件。")
|
parts = append(parts, "没有已安装的插件。")
|
||||||
}
|
}
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
@ -738,13 +718,6 @@ func (p *Plugin) handlePluginList(args map[string]interface{}) (interface{}, err
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(p.skills) > 0 {
|
|
||||||
parts = append(parts, fmt.Sprintf("\nSKILL 插件 (%d):", len(p.skills)))
|
|
||||||
for _, sk := range p.skills {
|
|
||||||
parts = append(parts, fmt.Sprintf(" %s v%s", sk.Name(), sk.Version()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
caps := p.dispatcher.Capabilities()
|
caps := p.dispatcher.Capabilities()
|
||||||
if len(caps) > 0 {
|
if len(caps) > 0 {
|
||||||
parts = append(parts, fmt.Sprintf("\nCapabilities (%d):", len(caps)))
|
parts = append(parts, fmt.Sprintf("\nCapabilities (%d):", len(caps)))
|
||||||
@ -753,8 +726,8 @@ func (p *Plugin) handlePluginList(args map[string]interface{}) (interface{}, err
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(p.sidecars) == 0 && len(p.skills) == 0 {
|
if len(p.sidecars) == 0 {
|
||||||
parts = append(parts, "没有已安装的插件。")
|
parts = append(parts, "没有已安装的插件(原生技能由 skillmgr 管理,用 skill_list 查看)。")
|
||||||
}
|
}
|
||||||
|
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
@ -811,11 +784,6 @@ func (p *Plugin) handlePluginInfo(args map[string]interface{}) (interface{}, err
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, sk := range p.skills {
|
|
||||||
if sk.Name() == name {
|
|
||||||
typ = "SKILL"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1264,12 +1232,23 @@ func (p *Plugin) Stop() error {
|
|||||||
sp.Close()
|
sp.Close()
|
||||||
}
|
}
|
||||||
p.sidecars = nil
|
p.sidecars = nil
|
||||||
p.skills = nil
|
|
||||||
p.manager = nil
|
p.manager = nil
|
||||||
p.sdk = nil
|
p.sdk = nil
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// publishSkillDetected 发布 skill_detected 事件,将纯 SKILL 条目移交原生
|
||||||
|
// skillmgr 插件注册。OpenClaw 兼容层不持有 native skill 状态。
|
||||||
|
func (p *Plugin) publishSkillDetected(s *sdk.PluginSDK, path string) {
|
||||||
|
s.Publish(&events.Event{
|
||||||
|
Type: events.EventSkillDetected,
|
||||||
|
Source: p.name,
|
||||||
|
Payload: map[string]interface{}{"path": path},
|
||||||
|
Timestamp: time.Now().Unix(),
|
||||||
|
})
|
||||||
|
log.Printf("[clawhubadapter] skill %s detected, handed off to skillmgr", filepath.Base(path))
|
||||||
|
}
|
||||||
|
|
||||||
func errorResult(msg string) interface{} {
|
func errorResult(msg string) interface{} {
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"isError": true,
|
"isError": true,
|
||||||
@ -1338,13 +1317,8 @@ func (p *Plugin) reloadPlugin(name string) error {
|
|||||||
case hasOCManifest || hasOCPackage:
|
case hasOCManifest || hasOCPackage:
|
||||||
return p.loadOCPlugin(p.sdk, pluginDir, name)
|
return p.loadOCPlugin(p.sdk, pluginDir, name)
|
||||||
default:
|
default:
|
||||||
sk, err := plugin.LoadSKILL(pluginDir)
|
// 纯 SKILL 类型:移交原生 skillmgr(与 Start 扫描行为一致)
|
||||||
if err != nil {
|
p.publishSkillDetected(p.sdk, pluginDir)
|
||||||
return fmt.Errorf("load skill: %w", err)
|
|
||||||
}
|
|
||||||
p.mu.Lock()
|
|
||||||
p.skills = append(p.skills, sk)
|
|
||||||
p.mu.Unlock()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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