Skip to content

Commit f910df9

Browse files
committed
Enhance SKILL.md skill support in Kitfile generation
Treat directories with SKILL.md as prompt skills and prompt metadata comes from frontmatter instead of filename-based prompt detection. Signed-off-by: Gorkem Ercan <gorkem.ercan@gmail.com>
1 parent 260d7d6 commit f910df9

7 files changed

Lines changed: 635 additions & 4 deletions

File tree

pkg/artifact/kitfile.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ type (
104104
}
105105

106106
Prompt struct {
107+
Name string `json:"name,omitempty" yaml:"name,omitempty"`
107108
Path string `json:"path,omitempty" yaml:"path,omitempty"`
108109
Description string `json:"description,omitempty" yaml:"description,omitempty"`
109110
*LayerInfo `json:",inline" yaml:",inline"`

pkg/lib/filesystem/unpack/filter.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,7 @@ func shouldUnpackLayer(layer any, filters []FilterConf) bool {
110110
// Code does not have a ID/name field so we can only match on path
111111
return matchesFilters("code", l.Path, filters)
112112
case artifact.Prompt:
113-
// Prompts do not have a ID/name field so we can only match on path
114-
return matchesFilters("prompts", l.Path, filters)
113+
return matchesFilters("prompts", l.Name, filters) || matchesFilters("prompts", l.Path, filters)
115114
default:
116115
return false
117116
}

pkg/lib/filesystem/unpack/filter_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package unpack
1919
import (
2020
"testing"
2121

22+
"github.com/kitops-ml/kitops/pkg/artifact"
2223
"github.com/stretchr/testify/assert"
2324
"github.com/stretchr/testify/require"
2425
)
@@ -111,6 +112,55 @@ func TestParseFilter_EdgeCases(t *testing.T) {
111112
}
112113
}
113114

115+
func TestShouldUnpackLayer_PromptByName(t *testing.T) {
116+
tests := []struct {
117+
name string
118+
prompt artifact.Prompt
119+
filter string
120+
expect bool
121+
}{
122+
{
123+
name: "match by name",
124+
prompt: artifact.Prompt{Name: "docx", Path: "skills/docx"},
125+
filter: "prompts:docx",
126+
expect: true,
127+
},
128+
{
129+
name: "match by path fallback",
130+
prompt: artifact.Prompt{Path: "skills/docx"},
131+
filter: "prompts:skills/docx",
132+
expect: true,
133+
},
134+
{
135+
name: "no match",
136+
prompt: artifact.Prompt{Name: "docx", Path: "skills/docx"},
137+
filter: "prompts:xlsx",
138+
expect: false,
139+
},
140+
{
141+
name: "prompts type without specific filter matches all",
142+
prompt: artifact.Prompt{Name: "docx", Path: "skills/docx"},
143+
filter: "prompts",
144+
expect: true,
145+
},
146+
{
147+
name: "empty name matches by path",
148+
prompt: artifact.Prompt{Path: "SKILL.md"},
149+
filter: "prompts:SKILL.md",
150+
expect: true,
151+
},
152+
}
153+
154+
for _, tt := range tests {
155+
t.Run(tt.name, func(t *testing.T) {
156+
fc, err := ParseFilter(tt.filter)
157+
require.NoError(t, err)
158+
result := shouldUnpackLayer(tt.prompt, []FilterConf{*fc})
159+
assert.Equal(t, tt.expect, result)
160+
})
161+
}
162+
}
163+
114164
func TestFiltersFromUnpackConf(t *testing.T) {
115165
tests := []struct {
116166
name string

pkg/lib/kitfile/generate/generate.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,27 @@ func GenerateKitfile(dir *DirectoryListing, packageOpt *artifact.Package) (*arti
9494
kitfile.Package = *packageOpt
9595
}
9696

97+
// SKILL.md: found at ROOT treat entire directory as a skill
98+
if found, skillPath := dirContainsSkillMD(*dir); found {
99+
output.Logf(output.LogLevelTrace, "SKILL.md found; treating as a skill directory")
100+
prompt := artifact.Prompt{Path: "."}
101+
if fm := parseSkillFrontmatter(skillPath); fm != nil {
102+
prompt.Name = fm.Name
103+
prompt.Description = fm.Description
104+
if kitfile.Package.Name == "" {
105+
kitfile.Package.Name = fm.Name
106+
}
107+
if kitfile.Package.Description == "" {
108+
kitfile.Package.Description = fm.Description
109+
}
110+
if kitfile.Package.License == "" {
111+
kitfile.Package.License = fm.License
112+
}
113+
}
114+
kitfile.Prompts = append(kitfile.Prompts, prompt)
115+
return kitfile, nil
116+
}
117+
97118
// We can make sure all files are included by including a layer with path '.'
98119
// However, we only want to do this if it is necessary
99120
includeCatchallSection := false
@@ -211,10 +232,19 @@ func GenerateKitfile(dir *DirectoryListing, packageOpt *artifact.Package) (*arti
211232
kitfile.Package.License = detectedLicenseType
212233
}
213234

235+
applySkillMetadataToPackage(kitfile, *dir)
236+
214237
return kitfile, nil
215238
}
216239

217240
func addDirToKitfile(kitfile *artifact.KitFile, dir DirectoryListing) (modelFiles []FileListing, err error) {
241+
if found, _ := dirContainsSkillMD(dir); found {
242+
output.Logf(output.LogLevelTrace, "Directory %s contains SKILL.md; treating as skill", dir.Path)
243+
prompt := buildPromptFromSkill(dir)
244+
kitfile.Prompts = append(kitfile.Prompts, prompt)
245+
return nil, nil
246+
}
247+
218248
switch dir.Name {
219249
case "docs":
220250
output.Logf(output.LogLevelTrace, "Directory %s interpreted as documentation", dir.Name)

pkg/lib/kitfile/generate/skill.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// Copyright 2026 The KitOps Authors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
//
15+
// SPDX-License-Identifier: Apache-2.0
16+
17+
package generate
18+
19+
import (
20+
"os"
21+
"strings"
22+
23+
"github.com/kitops-ml/kitops/pkg/artifact"
24+
"github.com/kitops-ml/kitops/pkg/output"
25+
26+
"go.yaml.in/yaml/v3"
27+
)
28+
29+
type SkillFrontmatter struct {
30+
Name string `yaml:"name"`
31+
Description string `yaml:"description"`
32+
License string `yaml:"license,omitempty"`
33+
}
34+
35+
func parseSkillFrontmatter(skillMDPath string) *SkillFrontmatter {
36+
data, err := os.ReadFile(skillMDPath)
37+
if err != nil {
38+
output.Logf(output.LogLevelWarn, "Failed to read %s: %s", skillMDPath, err)
39+
return nil
40+
}
41+
42+
content := string(data)
43+
if !strings.HasPrefix(content, "---\n") {
44+
return nil
45+
}
46+
47+
end := strings.Index(content[4:], "\n---")
48+
if end == -1 {
49+
return nil
50+
}
51+
52+
frontmatterYAML := content[4 : 4+end]
53+
if strings.TrimSpace(frontmatterYAML) == "" {
54+
return nil
55+
}
56+
57+
var fm SkillFrontmatter
58+
if err := yaml.Unmarshal([]byte(frontmatterYAML), &fm); err != nil {
59+
output.Logf(output.LogLevelWarn, "Malformed frontmatter in %s: %s", skillMDPath, err)
60+
return nil
61+
}
62+
return &fm
63+
}
64+
65+
func dirContainsSkillMD(dir DirectoryListing) (bool, string) {
66+
for _, file := range dir.Files {
67+
if strings.EqualFold(file.Name, "skill.md") {
68+
return true, file.Path
69+
}
70+
}
71+
return false, ""
72+
}
73+
74+
func buildPromptFromSkill(dir DirectoryListing) artifact.Prompt {
75+
prompt := artifact.Prompt{
76+
Path: dir.Path,
77+
}
78+
79+
found, skillPath := dirContainsSkillMD(dir)
80+
if !found {
81+
return prompt
82+
}
83+
84+
fm := parseSkillFrontmatter(skillPath)
85+
if fm != nil {
86+
prompt.Name = fm.Name
87+
prompt.Description = fm.Description
88+
}
89+
return prompt
90+
}
91+
92+
func applySkillMetadataToPackage(kitfile *artifact.KitFile, dir DirectoryListing) {
93+
var skillFrontmatters []*SkillFrontmatter
94+
for _, subDir := range dir.Subdirs {
95+
if found, skillPath := dirContainsSkillMD(subDir); found {
96+
if fm := parseSkillFrontmatter(skillPath); fm != nil {
97+
skillFrontmatters = append(skillFrontmatters, fm)
98+
}
99+
}
100+
}
101+
102+
if len(skillFrontmatters) == 0 {
103+
return
104+
}
105+
106+
if len(skillFrontmatters) == 1 {
107+
fm := skillFrontmatters[0]
108+
if kitfile.Package.Name == "" {
109+
kitfile.Package.Name = fm.Name
110+
}
111+
if kitfile.Package.Description == "" {
112+
kitfile.Package.Description = fm.Description
113+
}
114+
}
115+
116+
first := skillFrontmatters[0]
117+
if kitfile.Package.License == "" && first.License != "" {
118+
kitfile.Package.License = first.License
119+
output.Logf(output.LogLevelWarn, "Using license from skill %q", first.Name)
120+
}
121+
}

0 commit comments

Comments
 (0)