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///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 // /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() }