Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pkg/artifact/kitfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ type (
}

Prompt struct {
Name string `json:"name,omitempty" yaml:"name,omitempty"`
Path string `json:"path,omitempty" yaml:"path,omitempty"`
Description string `json:"description,omitempty" yaml:"description,omitempty"`
*LayerInfo `json:",inline" yaml:",inline"`
Expand Down
3 changes: 1 addition & 2 deletions pkg/lib/filesystem/unpack/filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,7 @@ func shouldUnpackLayer(layer any, filters []FilterConf) bool {
// Code does not have a ID/name field so we can only match on path
return matchesFilters("code", l.Path, filters)
case artifact.Prompt:
// Prompts do not have a ID/name field so we can only match on path
return matchesFilters("prompts", l.Path, filters)
return matchesFilters("prompts", l.Name, filters) || matchesFilters("prompts", l.Path, filters)
default:
return false
}
Expand Down
50 changes: 50 additions & 0 deletions pkg/lib/filesystem/unpack/filter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package unpack
import (
"testing"

"github.com/kitops-ml/kitops/pkg/artifact"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -111,6 +112,55 @@ func TestParseFilter_EdgeCases(t *testing.T) {
}
}

func TestShouldUnpackLayer_PromptByName(t *testing.T) {
tests := []struct {
name string
prompt artifact.Prompt
filter string
expect bool
}{
{
name: "match by name",
prompt: artifact.Prompt{Name: "docx", Path: "skills/docx"},
filter: "prompts:docx",
expect: true,
},
{
name: "match by path fallback",
prompt: artifact.Prompt{Path: "skills/docx"},
filter: "prompts:skills/docx",
expect: true,
},
{
name: "no match",
prompt: artifact.Prompt{Name: "docx", Path: "skills/docx"},
filter: "prompts:xlsx",
expect: false,
},
{
name: "prompts type without specific filter matches all",
prompt: artifact.Prompt{Name: "docx", Path: "skills/docx"},
filter: "prompts",
expect: true,
},
{
name: "empty name matches by path",
prompt: artifact.Prompt{Path: "SKILL.md"},
filter: "prompts:SKILL.md",
expect: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fc, err := ParseFilter(tt.filter)
require.NoError(t, err)
result := shouldUnpackLayer(tt.prompt, []FilterConf{*fc})
assert.Equal(t, tt.expect, result)
})
}
}

func TestFiltersFromUnpackConf(t *testing.T) {
tests := []struct {
name string
Expand Down
28 changes: 28 additions & 0 deletions pkg/lib/kitfile/generate/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,25 @@ func GenerateKitfile(dir *DirectoryListing, packageOpt *artifact.Package) (*arti
kitfile.Package = *packageOpt
}

// SKILL.md at root: treat entire directory as a single skill
if found, _ := dirContainsSkillMD(*dir); found {
output.Logf(output.LogLevelTrace, "SKILL.md found; treating as a skill directory")
prompt, fm := buildPromptFromSkill(*dir)
kitfile.Prompts = append(kitfile.Prompts, prompt)
if fm != nil {
if kitfile.Package.Name == "" {
kitfile.Package.Name = fm.Name
}
if kitfile.Package.Description == "" {
kitfile.Package.Description = fm.Description
}
if kitfile.Package.License == "" {
kitfile.Package.License = fm.License
}
}
return kitfile, nil
}

// We can make sure all files are included by including a layer with path '.'
// However, we only want to do this if it is necessary
includeCatchallSection := false
Expand Down Expand Up @@ -211,10 +230,19 @@ func GenerateKitfile(dir *DirectoryListing, packageOpt *artifact.Package) (*arti
kitfile.Package.License = detectedLicenseType
}

applySkillMetadataToPackage(kitfile, *dir)

Comment on lines +233 to +234
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this call ever have an effect? dir in this context is the directory used for kit init -- if it contains a skill.md, the block at the top

//  SKILL.md: found at ROOT treat entire directory as a skill
if found, skillPath := dirContainsSkillMD(*dir); found {

will early return on line 115.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeap, when root has a SKILL.md this is unreachable — the early return on line 115 handles that case. This call covers the multi-skill layout where the root has no SKILL.md but immediate subdirectories do (e.g. docx/SKILL.md, xlsx/SKILL.md). Each subdir is added as a prompt by addDirToKitfile, and applySkillMetadataToPackage then promotes license to the package.

return kitfile, nil
}

func addDirToKitfile(kitfile *artifact.KitFile, dir DirectoryListing) (modelFiles []FileListing, err error) {
if found, _ := dirContainsSkillMD(dir); found {
output.Logf(output.LogLevelTrace, "Directory %s contains SKILL.md; treating as skill", dir.Path)
prompt, _ := buildPromptFromSkill(dir)
kitfile.Prompts = append(kitfile.Prompts, prompt)
return nil, nil
}

switch dir.Name {
case "docs":
output.Logf(output.LogLevelTrace, "Directory %s interpreted as documentation", dir.Name)
Expand Down
133 changes: 133 additions & 0 deletions pkg/lib/kitfile/generate/skill.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Copyright 2026 The KitOps Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0

package generate

import (
"os"
"strings"

"github.com/kitops-ml/kitops/pkg/artifact"
"github.com/kitops-ml/kitops/pkg/output"

"go.yaml.in/yaml/v3"
)

type SkillFrontmatter struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
License string `yaml:"license,omitempty"`
}

func parseSkillFrontmatter(skillMDPath string) *SkillFrontmatter {
data, err := os.ReadFile(skillMDPath)
if err != nil {
output.Logf(output.LogLevelWarn, "Failed to read %s: %s", skillMDPath, err)
return nil
}

content := strings.ReplaceAll(string(data), "\r\n", "\n")
lines := strings.Split(content, "\n")
if len(lines) == 0 || lines[0] != "---" {
return nil
}

end := -1
for i := 1; i < len(lines); i++ {
if lines[i] == "---" {
end = i
break
}
}
if end == -1 {
return nil
}

frontmatterYAML := strings.Join(lines[1:end], "\n")
if strings.TrimSpace(frontmatterYAML) == "" {
return nil
}

var fm SkillFrontmatter
if err := yaml.Unmarshal([]byte(frontmatterYAML), &fm); err != nil {
output.Logf(output.LogLevelWarn, "Malformed frontmatter in %s: %s", skillMDPath, err)
return nil
}
return &fm
}

func dirContainsSkillMD(dir DirectoryListing) (bool, string) {
for _, file := range dir.Files {
if strings.EqualFold(file.Name, "skill.md") {
return true, file.Path
}
}
return false, ""
}

func buildPromptFromSkill(dir DirectoryListing) (artifact.Prompt, *SkillFrontmatter) {
prompt := artifact.Prompt{
Path: dir.Path,
}

found, skillPath := dirContainsSkillMD(dir)
if !found {
return prompt, nil
}

fm := parseSkillFrontmatter(skillPath)
if fm != nil {
prompt.Name = fm.Name
prompt.Description = fm.Description
}
return prompt, fm
}

func applySkillMetadataToPackage(kitfile *artifact.KitFile, dir DirectoryListing) {
var skillFrontmatters []*SkillFrontmatter
for _, subDir := range dir.Subdirs {
if found, skillPath := dirContainsSkillMD(subDir); found {
if fm := parseSkillFrontmatter(skillPath); fm != nil {
skillFrontmatters = append(skillFrontmatters, fm)
}
}
}

if len(skillFrontmatters) == 0 {
return
}

// Only promote name/description when the Kitfile contains exclusively
// prompt layers. A mixed Kitfile (model + skill) should not inherit the
// skill's name as the package name.
hasOnlyPrompts := kitfile.Model == nil && len(kitfile.Code) == 0 &&
len(kitfile.DataSets) == 0 && len(kitfile.Docs) == 0
if hasOnlyPrompts && len(skillFrontmatters) == 1 {
fm := skillFrontmatters[0]
if kitfile.Package.Name == "" {
kitfile.Package.Name = fm.Name
}
if kitfile.Package.Description == "" {
kitfile.Package.Description = fm.Description
}
}

first := skillFrontmatters[0]
if kitfile.Package.License == "" && first.License != "" {
kitfile.Package.License = first.License
output.Logf(output.LogLevelTrace, "Using license %q from skill %q", first.License, first.Name)
}
}
Loading