Add --as-skill flag to kit unpack for agent skill installation#1158
Add --as-skill flag to kit unpack for agent skill installation#1158gorkem wants to merge 9 commits intokitops-ml:mainfrom
Conversation
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>
There was a problem hiding this comment.
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 unpackwith--as-skill[=agents...]to install qualifying prompt layers as agent skills (global by default, project-scoped when-dis 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.
pkg/lib/skill/install.go
Outdated
| // 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. |
There was a problem hiding this comment.
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).
| // 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. |
| } | ||
|
|
||
| func homeDir() string { | ||
| h, _ := os.UserHomeDir() |
There was a problem hiding this comment.
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.
| 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") | |
| } |
| // 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 | ||
| } |
There was a problem hiding this comment.
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.
pkg/lib/skill/sanitize_test.go
Outdated
| @@ -0,0 +1,118 @@ | |||
| // Copyright 2024 The KitOps Authors. | |||
There was a problem hiding this comment.
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.
| // Copyright 2024 The KitOps Authors. | |
| // Copyright 2026 The KitOps Authors. |
- 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>
5688676 to
76e2183
Compare
| // Implicitly add prompts filter if no filters specified | ||
| if len(libOpts.FilterConfs) == 0 { | ||
| promptFilter, _ := kitfile.ParseFilter("prompts") | ||
| libOpts.FilterConfs = []kitfile.FilterConf{*promptFilter} | ||
| } |
There was a problem hiding this comment.
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" { |
There was a problem hiding this comment.
I dread the day someone makes an agent named auto 😄
There was a problem hiding this comment.
I tried empty but that does not work so we had to put something.
| 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(), ", ")) | ||
| } |
There was a problem hiding this comment.
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.
| 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(), ", ")) | |
| } |
pkg/cmd/unpack/cmd.go
Outdated
| 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(), ", ")) |
There was a problem hiding this comment.
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
| // 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 | ||
| } |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
I believe this does not verify that outPath is a subpath of skillDir
pkg/lib/skill/sanitize.go
Outdated
| if s == "" { | ||
| return "unnamed-skill" | ||
| } |
There was a problem hiding this comment.
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).
pkg/lib/skill/sanitize.go
Outdated
| if frontmatterName != "" { | ||
| return SanitizeName(frontmatterName) | ||
| } | ||
| if prompt.Name != "" { | ||
| return SanitizeName(prompt.Name) | ||
| } |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
It is not worth adding collision detection, users can handle it with overwrite/ignore-existing semantics
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>
1513e24 to
c337717
Compare
Summary
--as-skillflag tokit unpackthat installs SKILL.md prompt layers as agent skills for 45+ coding agents (Claude Code, Cursor, Windsurf, etc.)--as-skill=claude-code,cursor-dis specifiedpkg/lib/skillpackage, fixing a pre-existing bug wherekit initcouldn't read SKILL.md when run from a parent directoryNew package:
pkg/lib/skill/agents.gosanitize.goskillfile.goinstall.go