package skills
// Claude Code's skills, read from where Claude Code keeps them.
//
// They are worth reaching for because they are already on the machine and
// already curated: someone installed a plugin, or wrote a skill in ~/.claude,
// and Codey can follow the same page of instructions without anyone copying
// it by hand. But they were not written for Codey, so nothing here is allowed
// to be as strict as Discover is about Codey's own roots. A frontmatter name
// that does not match its directory, a name with a capital letter in it, a
// key Codey has never heard of — each of these is a normal Claude skill and a
// rejection Codey would have to explain to someone who did nothing wrong.
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
"gopkg.in/yaml.v3"
)
// ClaudeDirname is Claude Code's own directory, under the home directory and
// under the working root.
const ClaudeDirname = ".claude"
// claudeSkillsDir is where skills sit under ClaudeDirname, and under a
// plugin's install path.
const claudeSkillsDir = "skills"
// claudePluginsManifest, under the home ClaudeDirname, is Claude Code's record
// of which plugins are installed and where. It is read instead of the plugin
// cache next to it because the cache keeps every version ever fetched, and
// three copies of the same skill is two more than the model should see.
const claudePluginsManifest = "plugins/installed_plugins.json"
// claudeScanDepth is how many directory levels under a root a SKILL.md may be.
// Personal skills synced by Claude Code lie three deep
// (synced/<id>/<name>/SKILL.md); one more is margin, not a design.
const claudeScanDepth = 4
// SourceClaude is the Source of a skill read from Claude Code's personal
// root. A plugin's skills carry SourceClaude + ":" + the plugin's name, and a
// project-local one SourceClaudeProject.
const (
SourceClaude = "claude"
SourceClaudeProject = "claude:project"
)
// DiscoverClaude finds the skills Claude Code has on this machine: those
// installed by its plugins, its personal ones, and the project-local ones,
// in that order of precedence — later wins on a slug collision, as with
// Discover, and a skill someone put in ~/.claude by hand outranks one that
// arrived with a plugin.
//
// The rules are the same as Discover's: problems are reported, never fatal,
// and a root that is not there is not a problem.
func DiscoverClaude(home, workroot string) ([]Skill, []error) {
byName := map[string]Skill{}
var problems []error
add := func(found []Skill) {
for _, s := range found {
byName[s.Name] = s
}
}
if filepath.IsAbs(home) {
claude := filepath.Join(home, ClaudeDirname)
plugins, probs := pluginRoots(filepath.Join(claude, claudePluginsManifest))
problems = append(problems, probs...)
for _, p := range plugins {
found, probs := scanClaude(p.dir, SourceClaude+":"+p.name)
problems = append(problems, probs...)
add(found)
}
found, probs := scanClaude(filepath.Join(claude, claudeSkillsDir), SourceClaude)
problems = append(problems, probs...)
add(found)
}
if filepath.IsAbs(workroot) {
found, probs := scanClaude(filepath.Join(workroot, ClaudeDirname, claudeSkillsDir), SourceClaudeProject)
problems = append(problems, probs...)
add(found)
}
out := make([]Skill, 0, len(byName))
for _, s := range byName {
out = append(out, s)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, problems
}
// Merge combines Codey's own skills with Claude's, Codey's winning on a slug
// collision. The precedence is the reverse of the one inside each list, and
// deliberately: a skill in ~/.agents/skills is there because someone put it
// there, and one in a plugin cache is there because they installed something
// else. The deliberate act wins.
//
// The result is sorted by name, so it can stand wherever Discover's would.
func Merge(own, claude []Skill) []Skill {
byName := make(map[string]Skill, len(own)+len(claude))
for _, s := range claude {
byName[s.Name] = s
}
for _, s := range own {
byName[s.Name] = s
}
out := make([]Skill, 0, len(byName))
for _, s := range byName {
out = append(out, s)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// pluginRoot is one installed plugin's skills directory.
type pluginRoot struct {
name string // the plugin's name, without its marketplace
dir string // <installPath>/skills
}
// pluginManifest is the part of installed_plugins.json Codey reads. Each
// plugin lists every install Claude Code has made of it, so choosing between
// them is the whole job.
type pluginManifest struct {
Plugins map[string][]pluginInstall `json:"plugins"`
}
type pluginInstall struct {
InstallPath string `json:"installPath"`
InstalledAt string `json:"installedAt"`
LastUpdated string `json:"lastUpdated"`
}
// pluginRoots reads the manifest and returns one skills directory per plugin,
// sorted by plugin name so the shadowing between two plugins claiming the
// same slug is at least the same on every run.
//
// A missing manifest means no plugins, which is what a machine without Claude
// Code's plugin system looks like. An unreadable one is a problem: it was
// written by a program, so a broken one is news.
func pluginRoots(path string) ([]pluginRoot, []error) {
raw, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, []error{fmt.Errorf("%s: %w", path, err)}
}
var m pluginManifest
if err := json.Unmarshal(raw, &m); err != nil {
return nil, []error{fmt.Errorf("%s: not the JSON Claude Code writes: %w", path, err)}
}
var out []pluginRoot
var problems []error
for id, installs := range m.Plugins {
inst, ok := newest(installs)
if !ok {
continue
}
if !filepath.IsAbs(inst.InstallPath) {
problems = append(problems, fmt.Errorf("%s: plugin %s has no absolute installPath; reinstall it from Claude Code", path, id))
continue
}
name := id
if i := strings.IndexByte(id, '@'); i > 0 {
name = id[:i]
}
out = append(out, pluginRoot{name: name, dir: filepath.Join(inst.InstallPath, claudeSkillsDir)})
}
sort.Slice(out, func(i, j int) bool { return out[i].name < out[j].name })
return out, problems
}
// newest picks the install to read: the most recently updated, falling back to
// the most recently installed, and to the last listed when neither time can be
// read. Claude Code keeps the older versions in the manifest, and the skills
// in an old version are exactly the ones that were fixed in the new one.
func newest(installs []pluginInstall) (pluginInstall, bool) {
if len(installs) == 0 {
return pluginInstall{}, false
}
best, bestAt := installs[len(installs)-1], time.Time{}
for _, inst := range installs {
at := stamp(inst.LastUpdated)
if at.IsZero() {
at = stamp(inst.InstalledAt)
}
if !at.IsZero() && !at.Before(bestAt) {
best, bestAt = inst, at
}
}
return best, true
}
// stamp parses one of the manifest's timestamps, zero when it cannot.
func stamp(s string) time.Time {
t, err := time.Parse(time.RFC3339Nano, s)
if err != nil {
return time.Time{}
}
return t
}
// scanClaude reads one root to a bounded depth, returning every directory in
// it that holds a SKILL.md. It does not look inside a skill for more skills:
// a skill's own subdirectories are its reference material, and a SKILL.md
// two levels down would be a template or an example, not an instruction set.
func scanClaude(root, source string) ([]Skill, []error) {
var found []Skill
var problems []error
var walk func(dir string, depth int)
walk = func(dir string, depth int) {
entries, err := os.ReadDir(dir)
if err != nil {
if !os.IsNotExist(err) {
problems = append(problems, fmt.Errorf("%s: %w", dir, err))
}
return
}
for _, e := range entries {
sub := filepath.Join(dir, e.Name())
// os.Stat follows symlinks, so a symlinked-in skill directory is
// accepted here on the same terms scan accepts it.
if _, err := os.Stat(filepath.Join(sub, filename)); err == nil {
s, err := loadClaude(sub, source)
if err != nil {
problems = append(problems, err)
continue
}
found = append(found, s)
continue
}
// Descending follows only real directories. A symlink to a
// directory is not followed, because a link pointing back up the
// tree would otherwise turn a bounded scan into a slow one, and a
// synced tree has no reason to contain one.
if e.IsDir() && depth < claudeScanDepth {
walk(sub, depth+1)
}
}
}
walk(root, 1)
return found, problems
}
// loadClaude reads one Claude skill directory. The slug is worked out rather
// than demanded: see the note at the top of the file.
func loadClaude(dir, source string) (Skill, error) {
path := filepath.Join(dir, filename)
raw, err := os.ReadFile(path)
if err != nil {
return Skill{}, fmt.Errorf("%s: %w", path, err)
}
skill, err := parseClaude(raw, filepath.Base(dir))
if err != nil {
return Skill{}, fmt.Errorf("%s: %w", path, err)
}
abs, err := filepath.Abs(dir)
if err != nil {
abs = dir
}
skill.Dir, skill.Source = abs, source
return skill, nil
}
// parseClaude reads a Claude SKILL.md, deriving the slug from the frontmatter
// name when that is already a valid one and from the directory name otherwise.
// The frontmatter is preferred because it is what the author called the skill;
// the directory is the fallback because it is what Claude Code calls it, and
// it is at least always there.
//
// The description is the one thing that cannot be made up: without it the
// model has nothing to decide by, and a skill that is never invoked is not
// worth listing.
func parseClaude(content []byte, dirName string) (Skill, error) {
head, body, err := split(string(content))
if err != nil {
return Skill{}, err
}
var fm frontmatter
if err := yaml.Unmarshal([]byte(head), &fm); err != nil {
return Skill{}, fmt.Errorf("frontmatter is not valid YAML: %w", err)
}
if strings.TrimSpace(fm.Description) == "" {
return Skill{}, errors.New("frontmatter has no description; it is the only thing the model sees before invoking the skill")
}
slug := strings.TrimSpace(fm.Name)
if !slugPattern.MatchString(slug) {
slug = Slugify(dirName)
}
if slug == "" {
return Skill{}, fmt.Errorf("neither the frontmatter name %q nor the directory name %q can be made into a skill name; rename the directory using lower-case letters, digits and hyphens", fm.Name, dirName)
}
return Skill{
Name: slug,
Description: strings.TrimSpace(fm.Description),
Body: strings.TrimSpace(body),
}, nil
}
// Slugify makes a skill name out of a string that was not written to be one:
// lower-cased, spaces and underscores turned to hyphens, anything else dropped,
// and leading or trailing hyphens trimmed. It returns "" when nothing usable
// is left, which the caller must treat as no name at all.
func Slugify(s string) string {
var b strings.Builder
for _, r := range strings.ToLower(s) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
b.WriteRune(r)
case r == ' ', r == '_':
b.WriteRune('-')
}
}
out := strings.Trim(b.String(), "-")
if !slugPattern.MatchString(out) {
return ""
}
return out
}
// CopyClaude copies every skill DiscoverClaude finds into ~/.agents/skills,
// one directory per slug, and returns the slugs it copied. After it, they are
// personal skills like any other: Discover reads them, and Claude Code's
// directories are not consulted again.
//
// A slug that already has a directory in the destination is skipped and
// reported, not overwritten. A personal skill is one someone may have edited,
// and a copy that silently put it back the way the plugin shipped it would be
// the worst kind of data loss: no error, and the file still there.
//
// err is for the destination itself — an unknown home, a directory that
// cannot be created. Everything about an individual skill is a problem.
func CopyClaude(home, workroot string) (copied []string, problems []error, err error) {
if !filepath.IsAbs(home) {
return nil, nil, errors.New("the home directory is not known, so there is nowhere to copy the skills to")
}
dest := filepath.Join(home, Dirname)
if err := os.MkdirAll(dest, 0o755); err != nil {
return nil, nil, fmt.Errorf("creating %s: %w", dest, err)
}
found, problems := DiscoverClaude(home, workroot)
for _, s := range found {
target := filepath.Join(dest, s.Name)
if _, err := os.Lstat(target); err == nil {
problems = append(problems, fmt.Errorf("%s: already exists, so %s from %s was not copied over it; remove the directory first if you want the copy", target, s.Name, s.Dir))
continue
}
if err := copyTree(s.Dir, target); err != nil {
// A half-copied skill would load with half its reference files.
// Removing it leaves the next attempt a clean slot instead of an
// "already exists" it has to be told about.
os.RemoveAll(target)
problems = append(problems, fmt.Errorf("%s: copying to %s: %w", s.Dir, target, err))
continue
}
copied = append(copied, s.Name)
}
return copied, problems, nil
}
// copyTree copies the regular files and directories under src to dst, which
// must not exist. The executable bit is kept, because a skill ships its
// scripts and a script that arrives unrunnable is a support question.
//
// A symlink to a file is followed when it resolves inside the source tree and
// skipped otherwise: a link out to somewhere else on the machine is a
// reference, not part of the skill, and copying whatever it points at could be
// copying a lot. A symlink to a directory is skipped even inside the tree —
// a link back to an ancestor would otherwise copy the skill into itself until
// the path length ran out, and no skill has been seen that needs one.
func copyTree(src, dst string) error {
root, err := filepath.EvalSymlinks(src)
if err != nil {
return err
}
return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
info, err := d.Info()
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
resolved, err := filepath.EvalSymlinks(path)
if err != nil || !within(root, resolved) {
return nil
}
info, err = os.Stat(resolved)
if err != nil || !info.Mode().IsRegular() {
return nil
}
return copyFile(resolved, target, info.Mode())
}
switch {
case info.IsDir():
return os.MkdirAll(target, 0o755)
case info.Mode().IsRegular():
return copyFile(path, target, info.Mode())
}
// Sockets, devices and the like are not part of any skill.
return nil
})
}
// within reports whether path is root or lies under it.
func within(root, path string) bool {
rel, err := filepath.Rel(root, path)
if err != nil {
return false
}
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
}
// copyFile copies one regular file, keeping its permission bits.
func copyFile(src, dst string, mode os.FileMode) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode.Perm())
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
return out.Close()
}