26f897c1ec
release / release-please (push) Failing after 1m49s
docs / build (push) Failing after 6m34s
release / build-and-upload (arm64, linux) (push) Has been cancelled
release / build-and-upload (arm64, windows) (push) Has been cancelled
release / build-darwin (amd64, darwin) (push) Has been cancelled
release / checksums (push) Has been cancelled
release / finalize (push) Has been cancelled
release / build-darwin (arm64, darwin) (push) Has been cancelled
release / build-and-upload (amd64, linux) (push) Has been cancelled
release / build-and-upload (amd64, windows) (push) Has been cancelled
docs / deploy (push) Has been cancelled
124 lines
4.3 KiB
Go
124 lines
4.3 KiB
Go
package skill
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// InstallBases are the user-level agent skill parent directories, relative to
|
|
// the user's home directory, that init populates. `~/.claude/skills` is Claude
|
|
// Code's personal-skill location (OpenCode reads it too); `~/.agents/skills`
|
|
// is the vendor-neutral user-level convention Codex, OpenCode, Rovo Dev, and
|
|
// Pi all read.
|
|
var InstallBases = []string{
|
|
filepath.Join(".claude", "skills"),
|
|
filepath.Join(".agents", "skills"),
|
|
}
|
|
|
|
// InstallUser installs the skill into the agent skill directories under the
|
|
// current user's home directory. It returns the home-relative paths written.
|
|
func InstallUser() ([]string, error) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve home directory: %w", err)
|
|
}
|
|
return Install(home)
|
|
}
|
|
|
|
// Install writes SKILL.md into each agent skills directory under root
|
|
// (normally the user's home directory), creating directories as needed. It
|
|
// returns the root-relative paths written so the caller can report them.
|
|
// Writing is idempotent: re-running overwrites with identical content
|
|
// (refreshing a stale SKILL.md from an older version).
|
|
//
|
|
// Users may consolidate the two bases with a symlink - `.claude/skills` ->
|
|
// `.agents/skills`, the whole `.claude` dir -> `.agents`, or the reverse. Install
|
|
// follows such links transparently, including when the symlinked target dir does
|
|
// not exist yet (a plain os.MkdirAll would fail with "file exists" on a dangling
|
|
// symlink). Both logical bases stay readable afterward via the link.
|
|
func Install(root string) ([]string, error) {
|
|
content := []byte(Markdown())
|
|
written := make([]string, 0, len(InstallBases))
|
|
for _, base := range InstallBases {
|
|
rel := filepath.Join(base, Name, "SKILL.md")
|
|
path := filepath.Join(root, rel)
|
|
// Resolve any symlink components to a real directory before creating
|
|
// it, so a dangling symlink in the path does not collide with MkdirAll.
|
|
realDir, err := resolveThroughSymlinks(filepath.Dir(path))
|
|
if err != nil {
|
|
return written, err
|
|
}
|
|
if err := os.MkdirAll(realDir, 0o755); err != nil {
|
|
return written, err
|
|
}
|
|
if err := os.WriteFile(filepath.Join(realDir, "SKILL.md"), content, 0o644); err != nil {
|
|
return written, err
|
|
}
|
|
written = append(written, rel)
|
|
}
|
|
return written, nil
|
|
}
|
|
|
|
// Vendored reports the repo-relative paths of legacy vendored skill copies
|
|
// under repoRoot. Older no-mistakes versions wrote SKILL.md into each
|
|
// initialized repo; init uses this to tell users those copies are no longer
|
|
// needed. It never modifies the repo.
|
|
func Vendored(repoRoot string) []string {
|
|
var found []string
|
|
for _, base := range InstallBases {
|
|
rel := filepath.Join(base, Name, "SKILL.md")
|
|
if _, err := os.Stat(filepath.Join(repoRoot, rel)); err == nil {
|
|
found = append(found, rel)
|
|
}
|
|
}
|
|
return found
|
|
}
|
|
|
|
// resolveThroughSymlinks walks dir component by component and rewrites the path
|
|
// through any symlink it encounters, even when the symlink's target does not
|
|
// exist yet. The result contains no symlink components, so os.MkdirAll on it
|
|
// will not trip over a dangling symlink. dir must be absolute.
|
|
func resolveThroughSymlinks(dir string) (string, error) {
|
|
return resolveThroughSymlinksSeen(dir, make(map[string]struct{}))
|
|
}
|
|
|
|
func resolveThroughSymlinksSeen(dir string, seen map[string]struct{}) (string, error) {
|
|
clean := filepath.Clean(dir)
|
|
volume := filepath.VolumeName(clean)
|
|
cur := volume + string(filepath.Separator)
|
|
for _, part := range strings.Split(strings.TrimPrefix(clean, volume), string(filepath.Separator)) {
|
|
if part == "" {
|
|
continue
|
|
}
|
|
cur = filepath.Join(cur, part)
|
|
info, err := os.Lstat(cur)
|
|
if err != nil {
|
|
// This component does not exist yet; nothing left to resolve.
|
|
// Remaining parts are appended verbatim onto the resolved prefix.
|
|
continue
|
|
}
|
|
if info.Mode()&os.ModeSymlink == 0 {
|
|
continue
|
|
}
|
|
key := filepath.Clean(cur)
|
|
if _, ok := seen[key]; ok {
|
|
return "", fmt.Errorf("symlink cycle resolving %s", dir)
|
|
}
|
|
seen[key] = struct{}{}
|
|
target, err := os.Readlink(cur)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !filepath.IsAbs(target) {
|
|
target = filepath.Join(filepath.Dir(cur), target)
|
|
}
|
|
// The target may itself be or contain symlinks; resolve recursively.
|
|
if cur, err = resolveThroughSymlinksSeen(target, seen); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
return cur, nil
|
|
}
|