Skip to content

Commit b56063d

Browse files
grokifyclaude
andcommitted
feat(skills): add Kiro skills adapter and markdown skill parsing
Add Kiro steering file adapter for skills and extend core adapter to support markdown format with YAML frontmatter. Skills can now be read from both JSON and markdown canonical formats. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 48a5262 commit b56063d

File tree

3 files changed

+268
-2
lines changed

3 files changed

+268
-2
lines changed

skills/core/adapter.go

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"os"
88
"path/filepath"
99
"sort"
10+
"strings"
1011
"sync"
1112
)
1213

@@ -126,13 +127,29 @@ func Convert(data []byte, from, to string) ([]byte, error) {
126127
return DefaultRegistry.Convert(data, from, to)
127128
}
128129

129-
// ReadCanonicalFile reads a canonical skill.json file.
130+
// ReadCanonicalFile reads a canonical skill file (JSON or Markdown with YAML frontmatter).
130131
func ReadCanonicalFile(path string) (*Skill, error) {
131132
data, err := os.ReadFile(path)
132133
if err != nil {
133134
return nil, &ReadError{Path: path, Err: err}
134135
}
135136

137+
// Detect format: if it starts with "---" or has .md extension, parse as markdown
138+
ext := filepath.Ext(path)
139+
if ext == ".md" || (len(data) >= 3 && string(data[:3]) == "---") {
140+
skill, err := ParseSkillMarkdown(data)
141+
if err != nil {
142+
return nil, &ParseError{Format: "markdown", Path: path, Err: err}
143+
}
144+
// Infer name from filename if not set
145+
if skill.Name == "" {
146+
base := filepath.Base(path)
147+
skill.Name = strings.TrimSuffix(base, filepath.Ext(base))
148+
}
149+
return skill, nil
150+
}
151+
152+
// Fall back to JSON
136153
var skill Skill
137154
if err := json.Unmarshal(data, &skill); err != nil {
138155
return nil, &ParseError{Format: "canonical", Path: path, Err: err}
@@ -160,7 +177,10 @@ func WriteCanonicalFile(skill *Skill, path string) error {
160177
return nil
161178
}
162179

163-
// ReadCanonicalDir reads all skill.json files from subdirectories.
180+
// ReadCanonicalDir reads all skill files from a directory.
181+
// Supports both:
182+
// - Subdirectories with skill.json files
183+
// - Direct .md files with YAML frontmatter
164184
func ReadCanonicalDir(dir string) ([]*Skill, error) {
165185
entries, err := os.ReadDir(dir)
166186
if err != nil {
@@ -169,10 +189,21 @@ func ReadCanonicalDir(dir string) ([]*Skill, error) {
169189

170190
var skills []*Skill
171191
for _, entry := range entries {
192+
// Handle direct .md files (flat structure)
172193
if !entry.IsDir() {
194+
ext := filepath.Ext(entry.Name())
195+
if ext == ".md" {
196+
skillPath := filepath.Join(dir, entry.Name())
197+
skill, err := ReadCanonicalFile(skillPath)
198+
if err != nil {
199+
return nil, err
200+
}
201+
skills = append(skills, skill)
202+
}
173203
continue
174204
}
175205

206+
// Handle subdirectories with skill.json
176207
skillPath := filepath.Join(dir, entry.Name(), "skill.json")
177208
if _, err := os.Stat(skillPath); os.IsNotExist(err) {
178209
continue
@@ -207,3 +238,74 @@ func WriteSkillsToDir(skills []*Skill, dir string, adapterName string) error {
207238

208239
return nil
209240
}
241+
242+
// ParseSkillMarkdown parses a Markdown file with YAML frontmatter into a Skill.
243+
func ParseSkillMarkdown(data []byte) (*Skill, error) {
244+
content := string(data)
245+
246+
if !strings.HasPrefix(content, "---") {
247+
// No frontmatter, treat entire content as instructions
248+
return &Skill{Instructions: strings.TrimSpace(content)}, nil
249+
}
250+
251+
parts := strings.SplitN(content, "---", 3)
252+
if len(parts) < 3 {
253+
return &Skill{Instructions: strings.TrimSpace(content)}, nil
254+
}
255+
256+
skill := &Skill{}
257+
258+
// Parse simple YAML key: value pairs from frontmatter
259+
lines := strings.Split(strings.TrimSpace(parts[1]), "\n")
260+
for _, line := range lines {
261+
line = strings.TrimSpace(line)
262+
if line == "" || strings.HasPrefix(line, "#") {
263+
continue
264+
}
265+
idx := strings.Index(line, ":")
266+
if idx <= 0 {
267+
continue
268+
}
269+
key := strings.TrimSpace(line[:idx])
270+
value := strings.TrimSpace(line[idx+1:])
271+
// Remove quotes if present
272+
value = strings.Trim(value, "\"'")
273+
274+
switch key {
275+
case "name":
276+
skill.Name = value
277+
case "description":
278+
skill.Description = value
279+
case "triggers":
280+
skill.Triggers = parseList(value)
281+
case "dependencies":
282+
skill.Dependencies = parseList(value)
283+
case "scripts":
284+
skill.Scripts = parseList(value)
285+
case "references":
286+
skill.References = parseList(value)
287+
case "assets":
288+
skill.Assets = parseList(value)
289+
}
290+
}
291+
292+
// Body becomes instructions
293+
skill.Instructions = strings.TrimSpace(parts[2])
294+
295+
return skill, nil
296+
}
297+
298+
// parseList parses a comma-separated or bracket-enclosed list.
299+
func parseList(s string) []string {
300+
s = strings.Trim(s, "[]")
301+
parts := strings.Split(s, ",")
302+
var result []string
303+
for _, p := range parts {
304+
p = strings.TrimSpace(p)
305+
p = strings.Trim(p, "\"'")
306+
if p != "" {
307+
result = append(result, p)
308+
}
309+
}
310+
return result
311+
}

skills/kiro/adapter.go

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
// Package kiro provides the Kiro CLI skill adapter for steering files.
2+
package kiro
3+
4+
import (
5+
"bytes"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
11+
"github.com/agentplexus/assistantkit/skills/core"
12+
)
13+
14+
const (
15+
// AdapterName is the identifier for this adapter.
16+
AdapterName = "kiro"
17+
18+
// SteeringDir is the default steering directory name.
19+
SteeringDir = "steering"
20+
)
21+
22+
func init() {
23+
core.Register(&Adapter{})
24+
}
25+
26+
// Adapter converts between canonical Skill and Kiro CLI steering file format.
27+
type Adapter struct{}
28+
29+
// Name returns the adapter identifier.
30+
func (a *Adapter) Name() string {
31+
return AdapterName
32+
}
33+
34+
// SkillFileName returns the skill definition filename.
35+
// For Kiro, steering files are named <skill-name>.md directly.
36+
func (a *Adapter) SkillFileName() string {
37+
return ".md" // Used as suffix
38+
}
39+
40+
// DefaultDir returns the default directory name for Kiro steering files.
41+
func (a *Adapter) DefaultDir() string {
42+
return SteeringDir
43+
}
44+
45+
// Parse converts Kiro steering file bytes to canonical Skill.
46+
func (a *Adapter) Parse(data []byte) (*core.Skill, error) {
47+
content := string(data)
48+
lines := strings.SplitN(content, "\n", 2)
49+
50+
skill := &core.Skill{}
51+
52+
// Extract name from first line (# Title)
53+
if len(lines) > 0 && strings.HasPrefix(lines[0], "# ") {
54+
title := strings.TrimPrefix(lines[0], "# ")
55+
skill.Name = toKebabCase(title)
56+
skill.Description = title
57+
}
58+
59+
// Rest is instructions
60+
if len(lines) > 1 {
61+
skill.Instructions = strings.TrimSpace(lines[1])
62+
}
63+
64+
return skill, nil
65+
}
66+
67+
// Marshal converts canonical Skill to Kiro steering file bytes.
68+
func (a *Adapter) Marshal(skill *core.Skill) ([]byte, error) {
69+
var buf bytes.Buffer
70+
71+
// Write title from name (convert kebab-case to Title Case)
72+
title := toTitleCase(skill.Name)
73+
buf.WriteString(fmt.Sprintf("# %s\n\n", title))
74+
75+
// Write description if different from title
76+
if skill.Description != "" && skill.Description != title {
77+
buf.WriteString(fmt.Sprintf("%s\n\n", skill.Description))
78+
}
79+
80+
// Write instructions directly (they contain the markdown content)
81+
if skill.Instructions != "" {
82+
buf.WriteString(skill.Instructions)
83+
if !strings.HasSuffix(skill.Instructions, "\n") {
84+
buf.WriteString("\n")
85+
}
86+
}
87+
88+
return buf.Bytes(), nil
89+
}
90+
91+
// ReadFile reads a Kiro steering file and returns canonical Skill.
92+
func (a *Adapter) ReadFile(path string) (*core.Skill, error) {
93+
data, err := os.ReadFile(path)
94+
if err != nil {
95+
return nil, &core.ReadError{Path: path, Err: err}
96+
}
97+
98+
skill, err := a.Parse(data)
99+
if err != nil {
100+
if pe, ok := err.(*core.ParseError); ok {
101+
pe.Path = path
102+
}
103+
return nil, err
104+
}
105+
106+
// Infer name from filename if not set
107+
if skill.Name == "" {
108+
base := filepath.Base(path)
109+
skill.Name = strings.TrimSuffix(base, filepath.Ext(base))
110+
}
111+
112+
return skill, nil
113+
}
114+
115+
// WriteFile writes canonical Skill to a Kiro steering file.
116+
func (a *Adapter) WriteFile(skill *core.Skill, path string) error {
117+
data, err := a.Marshal(skill)
118+
if err != nil {
119+
return err
120+
}
121+
122+
dir := filepath.Dir(path)
123+
if err := os.MkdirAll(dir, core.DefaultDirMode); err != nil {
124+
return &core.WriteError{Path: path, Err: err}
125+
}
126+
127+
if err := os.WriteFile(path, data, core.DefaultFileMode); err != nil {
128+
return &core.WriteError{Path: path, Err: err}
129+
}
130+
131+
return nil
132+
}
133+
134+
// WriteSkillDir writes the skill as a steering file.
135+
// For Kiro, skills are flat files in the steering directory, not subdirectories.
136+
func (a *Adapter) WriteSkillDir(skill *core.Skill, baseDir string) error {
137+
// Ensure directory exists
138+
if err := os.MkdirAll(baseDir, core.DefaultDirMode); err != nil {
139+
return &core.WriteError{Path: baseDir, Err: err}
140+
}
141+
142+
// Write steering file: steering/<skill-name>.md
143+
steeringPath := filepath.Join(baseDir, skill.Name+".md")
144+
return a.WriteFile(skill, steeringPath)
145+
}
146+
147+
// toKebabCase converts "Title Case" or "Title-Case" to "title-case".
148+
func toKebabCase(s string) string {
149+
s = strings.ToLower(s)
150+
s = strings.ReplaceAll(s, " ", "-")
151+
return s
152+
}
153+
154+
// toTitleCase converts "kebab-case" to "Title Case".
155+
func toTitleCase(s string) string {
156+
words := strings.Split(s, "-")
157+
for i, word := range words {
158+
if len(word) > 0 {
159+
words[i] = strings.ToUpper(word[:1]) + word[1:]
160+
}
161+
}
162+
return strings.Join(words, " ")
163+
}

skills/skills.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import (
3636
// Import adapters for side-effect registration
3737
_ "github.com/agentplexus/assistantkit/skills/claude"
3838
_ "github.com/agentplexus/assistantkit/skills/codex"
39+
_ "github.com/agentplexus/assistantkit/skills/kiro"
3940
)
4041

4142
// Re-export core types for convenience

0 commit comments

Comments
 (0)