Skip to content

Commit 8bf1d61

Browse files
feat(adk): support skill
1 parent 3d494c0 commit 8bf1d61

File tree

5 files changed

+1161
-0
lines changed

5 files changed

+1161
-0
lines changed

adk/middlewares/skill/local.go

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
/*
2+
* Copyright 2025 CloudWeGo Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package skill
18+
19+
import (
20+
"context"
21+
"fmt"
22+
"os"
23+
"path/filepath"
24+
"strings"
25+
26+
"gopkg.in/yaml.v3"
27+
)
28+
29+
const skillFileName = "SKILL.md"
30+
31+
// LocalBackend is a Backend implementation that reads skills from the local filesystem.
32+
// Skills are stored in subdirectories of baseDir, each containing a SKILL.md file.
33+
type LocalBackend struct {
34+
// baseDir is the root directory containing skill subdirectories.
35+
baseDir string
36+
}
37+
38+
// LocalBackendConfig is the configuration for creating a LocalBackend.
39+
type LocalBackendConfig struct {
40+
// BaseDir is the root directory containing skill subdirectories.
41+
// Each subdirectory should contain a SKILL.md file with frontmatter and content.
42+
BaseDir string
43+
}
44+
45+
// NewLocalBackend creates a new LocalBackend with the given configuration.
46+
func NewLocalBackend(config *LocalBackendConfig) (*LocalBackend, error) {
47+
if config == nil {
48+
return nil, fmt.Errorf("config is required")
49+
}
50+
if config.BaseDir == "" {
51+
return nil, fmt.Errorf("baseDir is required")
52+
}
53+
54+
// Verify the directory exists
55+
info, err := os.Stat(config.BaseDir)
56+
if err != nil {
57+
return nil, fmt.Errorf("failed to stat baseDir: %w", err)
58+
}
59+
if !info.IsDir() {
60+
return nil, fmt.Errorf("baseDir is not a directory: %s", config.BaseDir)
61+
}
62+
63+
return &LocalBackend{
64+
baseDir: config.BaseDir,
65+
}, nil
66+
}
67+
68+
// skillFrontmatter represents the YAML frontmatter in a SKILL.md file.
69+
type skillFrontmatter struct {
70+
Name string `yaml:"name"`
71+
Description string `yaml:"description"`
72+
License *string `yaml:"license"`
73+
Compatibility *string `yaml:"compatibility"`
74+
Metadata map[string]any `yaml:"metadata"`
75+
AllowedTools []string `yaml:"allowed-tools"`
76+
}
77+
78+
// List returns all skills from the local filesystem.
79+
// It scans subdirectories of baseDir for SKILL.md files and parses them as skills.
80+
func (b *LocalBackend) List(ctx context.Context) ([]Skill, error) {
81+
var skills []Skill
82+
83+
entries, err := os.ReadDir(b.baseDir)
84+
if err != nil {
85+
return nil, fmt.Errorf("failed to read directory: %w", err)
86+
}
87+
88+
for _, entry := range entries {
89+
if !entry.IsDir() {
90+
continue
91+
}
92+
93+
skillDir := filepath.Join(b.baseDir, entry.Name())
94+
skillPath := filepath.Join(skillDir, skillFileName)
95+
96+
// Check if SKILL.md exists in this directory
97+
if _, err := os.Stat(skillPath); os.IsNotExist(err) {
98+
continue
99+
}
100+
101+
skill, err := b.loadSkillFromFile(skillPath)
102+
if err != nil {
103+
return nil, fmt.Errorf("failed to load skill from %s: %w", skillPath, err)
104+
}
105+
106+
skills = append(skills, skill)
107+
}
108+
109+
return skills, nil
110+
}
111+
112+
// Get returns a skill by name from the local filesystem.
113+
// It searches subdirectories for a SKILL.md file with matching name.
114+
func (b *LocalBackend) Get(ctx context.Context, name string) (Skill, error) {
115+
skills, err := b.List(ctx)
116+
if err != nil {
117+
return Skill{}, fmt.Errorf("failed to list skills: %w", err)
118+
}
119+
120+
for _, skill := range skills {
121+
if skill.Name == name {
122+
return skill, nil
123+
}
124+
}
125+
126+
return Skill{}, fmt.Errorf("skill not found: %s", name)
127+
}
128+
129+
// loadSkillFromFile loads a skill from a SKILL.md file.
130+
// The file format is:
131+
//
132+
// ---
133+
// name: skill-name
134+
// description: skill description
135+
// ---
136+
// Content goes here...
137+
func (b *LocalBackend) loadSkillFromFile(path string) (Skill, error) {
138+
data, err := os.ReadFile(path)
139+
if err != nil {
140+
return Skill{}, fmt.Errorf("failed to read file: %w", err)
141+
}
142+
143+
frontmatter, content, err := parseFrontmatter(string(data))
144+
if err != nil {
145+
return Skill{}, fmt.Errorf("failed to parse frontmatter: %w", err)
146+
}
147+
148+
var fm skillFrontmatter
149+
if err = yaml.Unmarshal([]byte(frontmatter), &fm); err != nil {
150+
return Skill{}, fmt.Errorf("failed to unmarshal frontmatter: %w", err)
151+
}
152+
153+
// Get the absolute path of the directory containing SKILL.md
154+
absDir, err := filepath.Abs(filepath.Dir(path))
155+
if err != nil {
156+
return Skill{}, fmt.Errorf("failed to get absolute path: %w", err)
157+
}
158+
159+
return Skill{
160+
Name: fm.Name,
161+
Description: fm.Description,
162+
License: fm.License,
163+
Compatibility: fm.Compatibility,
164+
Metadata: fm.Metadata,
165+
AllowedTools: fm.AllowedTools,
166+
Content: strings.TrimSpace(content),
167+
BaseDirectory: absDir,
168+
}, nil
169+
}
170+
171+
// parseFrontmatter parses a markdown file with YAML frontmatter.
172+
// Returns the frontmatter content (without ---), the remaining content, and any error.
173+
func parseFrontmatter(data string) (frontmatter string, content string, err error) {
174+
const delimiter = "---"
175+
176+
data = strings.TrimSpace(data)
177+
178+
// Must start with ---
179+
if !strings.HasPrefix(data, delimiter) {
180+
return "", "", fmt.Errorf("file does not start with frontmatter delimiter")
181+
}
182+
183+
// Find the closing ---
184+
rest := data[len(delimiter):]
185+
endIdx := strings.Index(rest, "\n"+delimiter)
186+
if endIdx == -1 {
187+
return "", "", fmt.Errorf("frontmatter closing delimiter not found")
188+
}
189+
190+
frontmatter = strings.TrimSpace(rest[:endIdx])
191+
content = rest[endIdx+len("\n"+delimiter):]
192+
193+
// Remove the newline after the closing ---
194+
if strings.HasPrefix(content, "\n") {
195+
content = content[1:]
196+
}
197+
198+
return frontmatter, content, nil
199+
}

0 commit comments

Comments
 (0)