Vectrum
Sign In
450 lines · 15.2 KB · Go
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()
}