Skip to content

Add --as-skill flag to kit unpack for agent skill installation#1158

Open
gorkem wants to merge 9 commits intokitops-ml:mainfrom
gorkem:worktree-unpack-as-skill
Open

Add --as-skill flag to kit unpack for agent skill installation#1158
gorkem wants to merge 9 commits intokitops-ml:mainfrom
gorkem:worktree-unpack-as-skill

Conversation

@gorkem
Copy link
Copy Markdown
Member

@gorkem gorkem commented Apr 6, 2026

Summary

  • Adds --as-skill flag to kit unpack that installs SKILL.md prompt layers as agent skills for 45+ coding agents (Claude Code, Cursor, Windsurf, etc.)
  • Auto-discovers installed agents when no agents are specified; supports explicit agent list via --as-skill=claude-code,cursor
  • Installs globally (user-scoped) by default; project-scoped when -d is specified
  • Strips prompt path prefixes so SKILL.md lands at the skill root regardless of nesting depth
  • Consolidates frontmatter parsing into shared pkg/lib/skill package, fixing a pre-existing bug where kit init couldn't read SKILL.md when run from a parent directory

New package: pkg/lib/skill/

File Purpose
agents.go Agent registry (45+ agents), detection, path resolution
sanitize.go Skill name derivation and sanitization
skillfile.go Tar layer reading, SKILL.md detection, frontmatter parsing
install.go Per-agent installation with path dedup, overwrite/ignore semantics

gorkem added 4 commits April 5, 2026 20:12
Prompt layers containing SKILL.md can now be installed directly as
agent skills (Claude Code, Cursor, Windsurf, etc.) via --as-skill.
Auto-discovers installed agents when no agents are specified. Installs
globally by default; use -d for project-scoped installation.

Signed-off-by: Gorkem Ercan <gorkem.ercan@gmail.com>
Use the listing context directory when reading SKILL.md so parsing works
reliably for nested paths and shifted working directories.
This prevents false read failures and preserves skill metadata
population during kitfile generation.

Signed-off-by: Gorkem Ercan <gorkem.ercan@gmail.com>
Ensure close and filesystem operations do not silently fail during
prompt-to-skill install and related tests.

Signed-off-by: Gorkem Ercan <gorkem.ercan@gmail.com>
Signed-off-by: Gorkem Ercan <gorkem.ercan@gmail.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a new “skill installation” mode to kit unpack so prompt layers containing SKILL.md can be installed as agent skills (with auto-detection and per-agent installation paths), while also consolidating SKILL frontmatter parsing into a shared library.

Changes:

  • Introduces pkg/lib/skill (agent registry/detection, skill name derivation, tar skill-layer reading/frontmatter parsing, and installation logic).
  • Extends kit unpack with --as-skill[=agents...] to install qualifying prompt layers as agent skills (global by default, project-scoped when -d is explicitly set).
  • Updates kitfile generation to reuse shared SKILL frontmatter parsing and fixes relative-path reading by tracking ContextDir.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
pkg/lib/skill/skillfile.go Reads prompt-layer tars, detects SKILL.md, parses YAML frontmatter
pkg/lib/skill/skillfile_test.go Unit tests for skill-layer reading and frontmatter parsing
pkg/lib/skill/sanitize.go Skill-name sanitization and name derivation rules
pkg/lib/skill/sanitize_test.go Tests for sanitization/derivation behavior
pkg/lib/skill/install.go Installs extracted tar entries into per-agent skill directories; strips prompt path prefixes
pkg/lib/skill/install_test.go Installation tests (overwrite/ignore/dedup/prefix stripping)
pkg/lib/skill/agents.go Registry of supported agents + detection and path resolution
pkg/lib/skill/agents_test.go Minimal registry/config test coverage
pkg/lib/kitfile/generate/filesystem.go Adds ContextDir so generated listings can resolve paths correctly
pkg/lib/kitfile/generate/skill.go Switches frontmatter parsing to shared skill package; uses ContextDir
pkg/lib/kitfile/generate/skill_test.go Updates tests for new parsing signature and SKILL filename expectations
pkg/lib/filesystem/unpack/options.go Adds SkillOptions to enable skill-mode behavior
pkg/lib/filesystem/unpack/core.go Implements prompt-layer “install as skill” path and summary/error handling
pkg/cmd/unpack/cmd.go Adds --as-skill CLI flag and parses agent auto-detect / explicit agent list

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +135 to +142
// Directory prompt — strip the prefix from all entries.
// Use path.Rel (not filepath.Rel) since both sides are forward-slash normalized.
result := make([]TarEntry, 0, len(entries))
for _, entry := range entries {
cleaned := normalizePath(entry.Header.Name)
// path package doesn't have Rel, so compute it manually:
// if cleaned starts with prefix + "/", take everything after.
// if cleaned == prefix, it's the directory entry itself — skip.
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

The comment block here is internally inconsistent: it says to use path.Rel, but then notes that the path package doesn't have Rel and implements a manual TrimPrefix approach. Please update the comment to match the actual implementation (or switch to path.Rel if you intend to use it).

Suggested change
// Directory prompt — strip the prefix from all entries.
// Use path.Rel (not filepath.Rel) since both sides are forward-slash normalized.
result := make([]TarEntry, 0, len(entries))
for _, entry := range entries {
cleaned := normalizePath(entry.Header.Name)
// path package doesn't have Rel, so compute it manually:
// if cleaned starts with prefix + "/", take everything after.
// if cleaned == prefix, it's the directory entry itself — skip.
// Directory prompt — strip the normalized prefix from all descendant entries.
// Since both sides use forward slashes, do this by removing prefix + "/"
// from matching paths rather than using filepath-specific path handling.
result := make([]TarEntry, 0, len(entries))
for _, entry := range entries {
cleaned := normalizePath(entry.Header.Name)
// If cleaned == prefix, it's the directory entry itself — skip it.
// Otherwise, if cleaned starts with prefix + "/", take everything after it.

Copilot uses AI. Check for mistakes.
}

func homeDir() string {
h, _ := os.UserHomeDir()
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

homeDir() ignores the error from os.UserHomeDir(). If it fails (or returns an empty string), subsequent filepath.Join calls can yield relative paths, which could cause global detection/installation to read or write under the current working directory unexpectedly. Consider propagating this error up (so GetGlobalSkillsDir/DetectInstalledAgents fail fast) or adding an explicit guard that returns an error when the home directory can't be determined.

Suggested change
h, _ := os.UserHomeDir()
h, err := os.UserHomeDir()
if err != nil || strings.TrimSpace(h) == "" {
if err != nil {
panic(fmt.Sprintf("unable to determine user home directory: %v", err))
}
panic("unable to determine user home directory: empty home directory")
}

Copilot uses AI. Check for mistakes.
Comment on lines +124 to +152
// ParseSkillFrontmatter extracts and parses YAML frontmatter from SKILL.md
// content. Returns nil if no valid frontmatter is found.
func ParseSkillFrontmatter(data []byte) *SkillFrontmatter {
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
}

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

var fm SkillFrontmatter
if err := yaml.Unmarshal([]byte(fmYAML), &fm); err != nil {
return nil
}
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

ParseSkillFrontmatter returns nil on yaml.Unmarshal errors, making malformed frontmatter indistinguishable from 'no frontmatter'. That also prevents callers (e.g., kit init) from warning the user about invalid YAML. Consider returning (*SkillFrontmatter, error) (or otherwise surfacing the parse error) so malformed frontmatter can be reported explicitly.

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,118 @@
// Copyright 2024 The KitOps Authors.
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

This new test file has a 2024 copyright header while the rest of the new skill package files use 2026. Please align the year to avoid confusing/inaccurate licensing headers.

Suggested change
// Copyright 2024 The KitOps Authors.
// Copyright 2026 The KitOps Authors.

Copilot uses AI. Check for mistakes.
- Fail fast in GetGlobalSkillsDir and DetectInstalledAgents when
  user home directory cannot be determined
- Fix stale comment referencing path.Rel in stripPromptPrefix
- Align copyright year to 2026 in sanitize_test.go

Signed-off-by: Gorkem Ercan <gorkem.ercan@gmail.com>
@gorkem gorkem force-pushed the worktree-unpack-as-skill branch from 5688676 to 76e2183 Compare April 7, 2026 17:31
Comment on lines +263 to +267
// Implicitly add prompts filter if no filters specified
if len(libOpts.FilterConfs) == 0 {
promptFilter, _ := kitfile.ParseFilter("prompts")
libOpts.FilterConfs = []kitfile.FilterConf{*promptFilter}
}
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.

Do we want to support filters when --as-skill is specified? What happens if I do

kit unpack ... --as-skill --filter=model

Or is it just caveat emptor here?

Looking below in how unpack functions when in 'skill mode', we force skipping anything that's not a prompt, so specifying the wrong kind of filter here would lead to nothing getting unpacked. Should we warn that only prompts filters are applicable when --as-skill is used?

func parseAsSkillFlag(value, unpackDir string, overwrite, ignoreExisting bool, modelRef *registry.Reference) (*skill.SkillInstallOptions, error) {
var agents []string

if value == "auto" {
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.

I dread the day someone makes an agent named auto 😄

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.

I tried empty but that does not work so we had to put something.

Comment on lines +297 to +302
if name == "" {
return nil, fmt.Errorf("invalid agent list: empty agent name in %q", value)
}
if !skill.IsValidAgentName(name) {
return nil, fmt.Errorf("unknown agent %q. Valid agents: %s", name, strings.Join(skill.ValidAgentNames(), ", "))
}
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.

Minor nit: I personally don't like using %q in fmt strings, as it uses double quotes, which requires escaping if e.g. embedded in json.

Not super important since we don't log as json currently, but maybe worth avoiding just in case.

Suggested change
if name == "" {
return nil, fmt.Errorf("invalid agent list: empty agent name in %q", value)
}
if !skill.IsValidAgentName(name) {
return nil, fmt.Errorf("unknown agent %q. Valid agents: %s", name, strings.Join(skill.ValidAgentNames(), ", "))
}
if name == "" {
return nil, fmt.Errorf("invalid agent list: empty agent name in '%s'", value)
}
if !skill.IsValidAgentName(name) {
return nil, fmt.Errorf("unknown agent '%s'. Valid agents: %s", name, strings.Join(skill.ValidAgentNames(), ", "))
}

return nil, fmt.Errorf("invalid agent list: empty agent name in %q", value)
}
if !skill.IsValidAgentName(name) {
return nil, fmt.Errorf("unknown agent %q. Valid agents: %s", name, strings.Join(skill.ValidAgentNames(), ", "))
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.

I wonder if there's a better way to expose these agent names, maybe as a help page or link to documentation? Currently the list is quite long, and I can also imagine it getting much longer:

adal, amp, antigravity, augment, bob, claude-code, cline, codebuddy, codex, command-code, continue, cortex, crush, cursor, deepagents, droid, firebender, gemini-cli, github-copilot, goose, iflow-cli, junie, kilo, kimi-cli, kiro-cli, kode, mcpjam, mistral-vibe, mux, neovate, openclaw, opencode, openhands, pi, pochi, qoder, qwen-code, replit, roo, trae, trae-cn, universal, warp, windsurf, zencoder

Comment on lines +179 to +200
// Skill mode: install as agent skill
if opts.SkillOptions != nil {
promptsFiltered++
result, sErr := installPromptAsSkill(ctx, store, layerDesc, entry, mediaType.Compression(), opts)
if sErr != nil {
output.Logf(output.LogLevelWarn, "Error reading prompt %q: %s", entry.Path, sErr)
skillErrors = append(skillErrors, fmt.Errorf("prompt %q: %s", entry.Path, sErr))
} else if result != nil {
skillsFound++
for _, ar := range result.Agents {
if ar.Err != nil {
output.Infof("Failed to install skill '%s' for %s: %s", result.SkillName, ar.Agent, ar.Err)
skillErrors = append(skillErrors, fmt.Errorf("skill '%s' for %s: %s", result.SkillName, ar.Agent, ar.Err))
} else if ar.Skipped {
output.Infof("Skipped skill '%s' for %s: already exists (use -o to overwrite)", result.SkillName, ar.Agent)
} else {
output.Infof("Installed skill '%s' for %s → %s", result.SkillName, ar.Agent, ar.Path)
}
}
}
continue
}
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.

Since this block always ends on a continue, does it make sense to instead have a separate unpackSkill function alongside unpackModelKit

Currently, we're tacking this on to the regular unpack code, but if opts.SkillOptions is not nil it takes an entirely separate flow. This overloads unpackModelKit and makes it more confusing.

//
// Returns an error if stripping would produce zero entries (indicates a
// separator mismatch or path alignment bug).
func stripPromptPrefix(entries []TarEntry, promptPath string) ([]TarEntry, error) {
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.

This function is rather complex -- can we simplify by just stripping prefixes as we write files? I.e. provide the promptPath to installForAgent and update how outPath is computed on line 284? I'm not sure I see the benefit to doing it in advance.

}

for _, entry := range entries {
outPath := filepath.Join(skillDir, entry.Header.Name)
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.

I believe this does not verify that outPath is a subpath of skillDir

Comment on lines +45 to +47
if s == "" {
return "unnamed-skill"
}
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.

I think it would be more idiomatic to return an error here. This could lead to confusing errors (if e.g. multiple skills resolve to unnamed-skill. The call chain for this function does return an error (installPromptAsSkill in core.go, currently), so we can propagate the error (or handle it).

Comment on lines +60 to +65
if frontmatterName != "" {
return SanitizeName(frontmatterName)
}
if prompt.Name != "" {
return SanitizeName(prompt.Name)
}
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.

If SanitizeName(frontmatterName) is "unnamed-skill", does it make sense to fall back to the next option?

// 2. prompt.Name (from Kitfile)
// 3. Parent directory name (for SKILL.md files or directory prompts)
// 4. Fallback: repository name from modelRef
func DeriveSkillName(frontmatterName string, prompt artifact.Prompt, modelRef *registry.Reference) string {
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.

Since we're deriving skill names, do we do anything to verify that the resulting set of names does not collide? my-skill and .my-skill will collide based on SanitizeName semantics. Not crucially important to fix, since you kind of have to try to make it happen, though.

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.

It is not worth adding collision detection, users can handle it with overwrite/ignore-existing semantics

gorkem added 4 commits April 9, 2026 17:45
Return prefix-strip errors from InstallSkill so unpack fails fast on
misaligned prompt paths.

Validate skill names and tar entry paths before writes to prevent unsafe or
partial installs.

Signed-off-by: Gorkem Ercan <gorkem.ercan@gmail.com>
Signed-off-by: Gorkem Ercan <gorkem.ercan@gmail.com>
Handle single-file prompts only when the layer contains exactly one
matching file entry, preventing directory prompt layers from being
misclassified and stripped incorrectly.

Signed-off-by: Gorkem Ercan <gorkem.ercan@gmail.com>
Make sanitizeName return empty for invalid/blank input and move
"unnamed-skill" fallback into DeriveSkillName so fallback priority is
preserved before defaulting.

Signed-off-by: Gorkem Ercan <gorkem.ercan@gmail.com>
@gorkem gorkem force-pushed the worktree-unpack-as-skill branch from 1513e24 to c337717 Compare April 9, 2026 23:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants