Skip to content

Commit 356b833

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 356b833

9 files changed

Lines changed: 637 additions & 9 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/cmd/kitimport/util.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ func generateKitfile(dirContents *kfgen.DirectoryListing, repo string, outDir st
5353
Authors: []string{sections[len(sections)-2]},
5454
}
5555
}
56-
kitfile, err := kfgen.GenerateKitfile(dirContents, modelPackage)
56+
kitfile, err := kfgen.GenerateKitfile(dirContents, modelPackage, outDir)
5757
if err != nil {
5858
return nil, fmt.Errorf("failed to generate Kitfile: %w", err)
5959
}

pkg/cmd/kitinit/cmd.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ func runCommand(opts *initOptions) func(*cobra.Command, []string) error {
110110
if err != nil {
111111
return output.Fatalf("Error processing directory: %s", err)
112112
}
113-
kitfile, err := kfgen.GenerateKitfile(dirContents, modelPackage)
113+
kitfile, err := kfgen.GenerateKitfile(dirContents, modelPackage, opts.path)
114114
if err != nil {
115115
return output.Fatalf("Error generating Kitfile: %s", err)
116116
}

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: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ var promptFilePatterns = []string{
8585
// Generate a basic Kitfile by looking at the contents of a directory. Parameter
8686
// packageOpt can be used to define metadata for the Kitfile (i.e. the package
8787
// section), which is left empty if the parameter is nil.
88-
func GenerateKitfile(dir *DirectoryListing, packageOpt *artifact.Package) (*artifact.KitFile, error) {
88+
func GenerateKitfile(dir *DirectoryListing, packageOpt *artifact.Package, contextDir string) (*artifact.KitFile, error) {
8989
output.Logf(output.LogLevelTrace, "Generating Kitfile in %s", dir.Path)
9090
kitfile := &artifact.KitFile{
9191
ManifestVersion: "1.0.0",
@@ -94,6 +94,28 @@ 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+
fullPath := filepath.Join(contextDir, skillPath)
102+
if fm := parseSkillFrontmatter(fullPath); fm != nil {
103+
prompt.Name = fm.Name
104+
prompt.Description = fm.Description
105+
if kitfile.Package.Name == "" {
106+
kitfile.Package.Name = fm.Name
107+
}
108+
if kitfile.Package.Description == "" {
109+
kitfile.Package.Description = fm.Description
110+
}
111+
if kitfile.Package.License == "" {
112+
kitfile.Package.License = fm.License
113+
}
114+
}
115+
kitfile.Prompts = append(kitfile.Prompts, prompt)
116+
return kitfile, nil
117+
}
118+
97119
// We can make sure all files are included by including a layer with path '.'
98120
// However, we only want to do this if it is necessary
99121
includeCatchallSection := false
@@ -162,7 +184,7 @@ func GenerateKitfile(dir *DirectoryListing, packageOpt *artifact.Package) (*arti
162184
}
163185

164186
for _, subDir := range dir.Subdirs {
165-
dirModelFiles, err := addDirToKitfile(kitfile, subDir)
187+
dirModelFiles, err := addDirToKitfile(kitfile, subDir, contextDir)
166188
if err != nil {
167189
output.Logf(output.LogLevelTrace, "Failed to determine type for directory %s: %s", subDir.Path, err)
168190
unprocessedDirPaths = append(unprocessedDirPaths, subDir.Path)
@@ -211,10 +233,19 @@ func GenerateKitfile(dir *DirectoryListing, packageOpt *artifact.Package) (*arti
211233
kitfile.Package.License = detectedLicenseType
212234
}
213235

236+
applySkillMetadataToPackage(kitfile, *dir, contextDir)
237+
214238
return kitfile, nil
215239
}
216240

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

pkg/lib/kitfile/generate/skill.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
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+
"path/filepath"
22+
"strings"
23+
24+
"github.com/kitops-ml/kitops/pkg/artifact"
25+
"github.com/kitops-ml/kitops/pkg/output"
26+
27+
"go.yaml.in/yaml/v3"
28+
)
29+
30+
type SkillFrontmatter struct {
31+
Name string `yaml:"name"`
32+
Description string `yaml:"description"`
33+
License string `yaml:"license,omitempty"`
34+
}
35+
36+
func parseSkillFrontmatter(skillMDPath string) *SkillFrontmatter {
37+
data, err := os.ReadFile(skillMDPath)
38+
if err != nil {
39+
output.Logf(output.LogLevelWarn, "Failed to read %s: %s", skillMDPath, err)
40+
return nil
41+
}
42+
43+
content := string(data)
44+
if !strings.HasPrefix(content, "---\n") {
45+
return nil
46+
}
47+
48+
end := strings.Index(content[4:], "\n---")
49+
if end == -1 {
50+
return nil
51+
}
52+
53+
frontmatterYAML := content[4 : 4+end]
54+
if strings.TrimSpace(frontmatterYAML) == "" {
55+
return nil
56+
}
57+
58+
var fm SkillFrontmatter
59+
if err := yaml.Unmarshal([]byte(frontmatterYAML), &fm); err != nil {
60+
output.Logf(output.LogLevelWarn, "Malformed frontmatter in %s: %s", skillMDPath, err)
61+
return nil
62+
}
63+
return &fm
64+
}
65+
66+
func dirContainsSkillMD(dir DirectoryListing) (bool, string) {
67+
for _, file := range dir.Files {
68+
if strings.EqualFold(file.Name, "skill.md") {
69+
return true, file.Path
70+
}
71+
}
72+
return false, ""
73+
}
74+
75+
func buildPromptFromSkill(dir DirectoryListing, contextDir string) artifact.Prompt {
76+
prompt := artifact.Prompt{
77+
Path: dir.Path,
78+
}
79+
80+
found, skillPath := dirContainsSkillMD(dir)
81+
if !found {
82+
return prompt
83+
}
84+
85+
fullPath := filepath.Join(contextDir, skillPath)
86+
fm := parseSkillFrontmatter(fullPath)
87+
if fm != nil {
88+
prompt.Name = fm.Name
89+
prompt.Description = fm.Description
90+
}
91+
return prompt
92+
}
93+
94+
func applySkillMetadataToPackage(kitfile *artifact.KitFile, dir DirectoryListing, contextDir string) {
95+
var skillFrontmatters []*SkillFrontmatter
96+
for _, subDir := range dir.Subdirs {
97+
if found, skillPath := dirContainsSkillMD(subDir); found {
98+
fullPath := filepath.Join(contextDir, skillPath)
99+
if fm := parseSkillFrontmatter(fullPath); fm != nil {
100+
skillFrontmatters = append(skillFrontmatters, fm)
101+
}
102+
}
103+
}
104+
105+
if len(skillFrontmatters) == 0 {
106+
return
107+
}
108+
109+
if len(skillFrontmatters) == 1 {
110+
fm := skillFrontmatters[0]
111+
if kitfile.Package.Name == "" {
112+
kitfile.Package.Name = fm.Name
113+
}
114+
if kitfile.Package.Description == "" {
115+
kitfile.Package.Description = fm.Description
116+
}
117+
}
118+
119+
first := skillFrontmatters[0]
120+
if kitfile.Package.License == "" && first.License != "" {
121+
kitfile.Package.License = first.License
122+
output.Logf(output.LogLevelWarn, "Using license from skill %q", first.Name)
123+
}
124+
}

0 commit comments

Comments
 (0)