Skip to content

Commit acb5b1b

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

File tree

5 files changed

+1184
-0
lines changed

5 files changed

+1184
-0
lines changed

adk/middlewares/skill/local.go

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
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+
// List returns all skills from the local filesystem.
69+
// It scans subdirectories of baseDir for SKILL.md files and parses them as skills.
70+
func (b *LocalBackend) List(ctx context.Context) ([]FrontMatter, error) {
71+
skills, err := b.list(ctx)
72+
if err != nil {
73+
return nil, fmt.Errorf("failed to list skills: %w", err)
74+
}
75+
76+
matters := make([]FrontMatter, 0, len(skills))
77+
for _, skill := range skills {
78+
matters = append(matters, skill.FrontMatter)
79+
}
80+
81+
return matters, nil
82+
}
83+
84+
// Get returns a skill by name from the local filesystem.
85+
// It searches subdirectories for a SKILL.md file with matching name.
86+
func (b *LocalBackend) Get(ctx context.Context, name string) (Skill, error) {
87+
skills, err := b.list(ctx)
88+
if err != nil {
89+
return Skill{}, fmt.Errorf("failed to list skills: %w", err)
90+
}
91+
92+
for _, skill := range skills {
93+
if skill.Name == name {
94+
return skill, nil
95+
}
96+
}
97+
98+
return Skill{}, fmt.Errorf("skill not found: %s", name)
99+
}
100+
101+
func (b *LocalBackend) list(ctx context.Context) ([]Skill, error) {
102+
var skills []Skill
103+
104+
entries, err := os.ReadDir(b.baseDir)
105+
if err != nil {
106+
return nil, fmt.Errorf("failed to read directory: %w", err)
107+
}
108+
109+
for _, entry := range entries {
110+
if !entry.IsDir() {
111+
continue
112+
}
113+
114+
skillDir := filepath.Join(b.baseDir, entry.Name())
115+
skillPath := filepath.Join(skillDir, skillFileName)
116+
117+
// Check if SKILL.md exists in this directory
118+
if _, err := os.Stat(skillPath); os.IsNotExist(err) {
119+
continue
120+
}
121+
122+
skill, err := b.loadSkillFromFile(skillPath)
123+
if err != nil {
124+
return nil, fmt.Errorf("failed to load skill from %s: %w", skillPath, err)
125+
}
126+
127+
skills = append(skills, skill)
128+
}
129+
130+
return skills, nil
131+
}
132+
133+
// loadSkillFromFile loads a skill from a SKILL.md file.
134+
// The file format is:
135+
//
136+
// ---
137+
// name: skill-name
138+
// description: skill description
139+
// ---
140+
// Content goes here...
141+
func (b *LocalBackend) loadSkillFromFile(path string) (Skill, error) {
142+
data, err := os.ReadFile(path)
143+
if err != nil {
144+
return Skill{}, fmt.Errorf("failed to read file: %w", err)
145+
}
146+
147+
frontmatter, content, err := parseFrontmatter(string(data))
148+
if err != nil {
149+
return Skill{}, fmt.Errorf("failed to parse frontmatter: %w", err)
150+
}
151+
152+
var fm FrontMatter
153+
if err = yaml.Unmarshal([]byte(frontmatter), &fm); err != nil {
154+
return Skill{}, fmt.Errorf("failed to unmarshal frontmatter: %w", err)
155+
}
156+
157+
// Get the absolute path of the directory containing SKILL.md
158+
absDir, err := filepath.Abs(filepath.Dir(path))
159+
if err != nil {
160+
return Skill{}, fmt.Errorf("failed to get absolute path: %w", err)
161+
}
162+
163+
return Skill{
164+
FrontMatter: FrontMatter{
165+
Name: fm.Name,
166+
Description: fm.Description,
167+
},
168+
Content: strings.TrimSpace(content),
169+
BaseDirectory: absDir,
170+
}, nil
171+
}
172+
173+
// parseFrontmatter parses a markdown file with YAML frontmatter.
174+
// Returns the frontmatter content (without ---), the remaining content, and any error.
175+
func parseFrontmatter(data string) (frontmatter string, content string, err error) {
176+
const delimiter = "---"
177+
178+
data = strings.TrimSpace(data)
179+
180+
// Must start with ---
181+
if !strings.HasPrefix(data, delimiter) {
182+
return "", "", fmt.Errorf("file does not start with frontmatter delimiter")
183+
}
184+
185+
// Find the closing ---
186+
rest := data[len(delimiter):]
187+
endIdx := strings.Index(rest, "\n"+delimiter)
188+
if endIdx == -1 {
189+
return "", "", fmt.Errorf("frontmatter closing delimiter not found")
190+
}
191+
192+
frontmatter = strings.TrimSpace(rest[:endIdx])
193+
content = rest[endIdx+len("\n"+delimiter):]
194+
195+
// Remove the newline after the closing ---
196+
if strings.HasPrefix(content, "\n") {
197+
content = content[1:]
198+
}
199+
200+
return frontmatter, content, nil
201+
}

0 commit comments

Comments
 (0)