Add support for the --allow-hidden-dirs flag in `gh skill preview`, matching the existing pattern in `gh skill install`. This allows users to preview skills located in hidden directories (e.g. .claude/skills/, .agents/skills/). Changes: - Add AllowHiddenDirs field to PreviewOptions - Register --allow-hidden-dirs flag on the preview command - Switch from DiscoverSkills to DiscoverSkillsWithOptions to get all skills including hidden-dir ones - Add filterHiddenDirSkills to exclude hidden-dir skills by default, showing a hint when they are found but excluded - Print a warning when --allow-hidden-dirs is used and hidden skills are present - Return an error when only hidden-dir skills exist without the flag Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1045 lines
32 KiB
Go
1045 lines
32 KiB
Go
package discovery
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
|
|
"github.com/cli/cli/v2/api"
|
|
"github.com/cli/cli/v2/internal/skills/frontmatter"
|
|
)
|
|
|
|
// specNamePattern matches the strict agentskills.io name spec:
|
|
// 1-64 chars, lowercase alphanumeric + hyphens, no leading/trailing/consecutive hyphens.
|
|
var specNamePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`)
|
|
|
|
// TreeTooLargeError is returned when a repository's git tree exceeds the
|
|
// GitHub API truncation limit and full skill discovery is not possible.
|
|
type TreeTooLargeError struct {
|
|
Owner string
|
|
Repo string
|
|
}
|
|
|
|
func (e *TreeTooLargeError) Error() string {
|
|
return fmt.Sprintf("repository tree for %s/%s is too large for full discovery", e.Owner, e.Repo)
|
|
}
|
|
|
|
// safeNamePattern matches names that are safe for filesystem use during discovery.
|
|
// Allows letters (any case), numbers, hyphens, underscores, dots, and spaces.
|
|
// Must start with a letter or number. This matches copilot-agent-runtime's SKILL_NAME_REGEX.
|
|
var safeNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._\- ]*$`)
|
|
|
|
// Skill represents a discovered skill in a repository.
|
|
type Skill struct {
|
|
Name string
|
|
Namespace string // author/scope prefix for namespaced skills
|
|
Description string
|
|
Path string // path within the repo, e.g. "skills/git-commit"
|
|
BlobSHA string // SHA of the SKILL.md blob
|
|
TreeSHA string // SHA of the skill directory tree
|
|
Convention string // which directory convention matched
|
|
}
|
|
|
|
// DisplayName returns the skill name, prefixed with namespace if present
|
|
// to disambiguate skills from different authors in the same repository.
|
|
// Skills discovered via non-standard conventions (plugins, root) include
|
|
// a convention tag to distinguish them from identically-named skills in
|
|
// the standard skills/ directory.
|
|
func (s Skill) DisplayName() string {
|
|
name := s.Name
|
|
if s.Namespace != "" {
|
|
name = s.Namespace + "/" + name
|
|
}
|
|
switch s.Convention {
|
|
case "plugins":
|
|
return "[plugins] " + name
|
|
case "root":
|
|
return "[root] " + name
|
|
case "hidden-dir", "hidden-dir-namespaced":
|
|
return "[hidden-dir] " + name
|
|
default:
|
|
return name
|
|
}
|
|
}
|
|
|
|
// InstallName returns the relative path used for the install directory.
|
|
// For namespaced skills it returns "namespace/name" (creating a nested directory),
|
|
// otherwise it returns the plain name. Callers should use filepath.FromSlash
|
|
// when building OS-specific paths from this value.
|
|
func (s Skill) InstallName() string {
|
|
if s.Namespace != "" {
|
|
return s.Namespace + "/" + s.Name
|
|
}
|
|
return s.Name
|
|
}
|
|
|
|
// IsHiddenDirConvention returns true if the skill was discovered in a hidden
|
|
// (dot-prefixed) directory such as .claude/skills/ or .agents/skills/.
|
|
func (s Skill) IsHiddenDirConvention() bool {
|
|
return s.Convention == "hidden-dir" || s.Convention == "hidden-dir-namespaced"
|
|
}
|
|
|
|
// HasHiddenDirSkills returns true if any of the given skills were discovered
|
|
// in hidden directories.
|
|
func HasHiddenDirSkills(skills []Skill) bool {
|
|
for _, s := range skills {
|
|
if s.IsHiddenDirConvention() {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// HiddenDirFilterResult holds the outcome of partitioning skills into standard
|
|
// and hidden-dir buckets.
|
|
type HiddenDirFilterResult struct {
|
|
Standard []Skill
|
|
HiddenCount int
|
|
}
|
|
|
|
// PartitionHiddenDirSkills splits skills into standard and hidden-dir groups.
|
|
func PartitionHiddenDirSkills(skills []Skill) HiddenDirFilterResult {
|
|
var r HiddenDirFilterResult
|
|
for _, s := range skills {
|
|
if s.IsHiddenDirConvention() {
|
|
r.HiddenCount++
|
|
} else {
|
|
r.Standard = append(r.Standard, s)
|
|
}
|
|
}
|
|
return r
|
|
}
|
|
|
|
// ResolvedRef contains the resolved git reference and its SHA.
|
|
type ResolvedRef struct {
|
|
Ref string // fully qualified ref (refs/heads/*, refs/tags/*) or commit SHA
|
|
SHA string // commit SHA
|
|
}
|
|
|
|
// IsFullyQualifiedRef returns true if ref uses the "refs/heads/" or "refs/tags/" prefix.
|
|
func IsFullyQualifiedRef(ref string) bool {
|
|
return strings.HasPrefix(ref, "refs/heads/") || strings.HasPrefix(ref, "refs/tags/")
|
|
}
|
|
|
|
// ShortRef strips the "refs/heads/" or "refs/tags/" prefix from a fully qualified ref,
|
|
// returning the short name. If the ref is not fully qualified it is returned as-is.
|
|
func ShortRef(ref string) string {
|
|
if after, ok := strings.CutPrefix(ref, "refs/heads/"); ok {
|
|
return after
|
|
}
|
|
if after, ok := strings.CutPrefix(ref, "refs/tags/"); ok {
|
|
return after
|
|
}
|
|
return ref
|
|
}
|
|
|
|
type treeEntry struct {
|
|
Path string `json:"path"`
|
|
Mode string `json:"mode"`
|
|
Type string `json:"type"`
|
|
SHA string `json:"sha"`
|
|
Size int `json:"size"`
|
|
}
|
|
|
|
// SkillFile represents a file within a skill directory.
|
|
type SkillFile struct {
|
|
Path string // relative path within the skill directory
|
|
SHA string // blob SHA for fetching content
|
|
Size int // file size in bytes
|
|
}
|
|
|
|
type treeResponse struct {
|
|
SHA string `json:"sha"`
|
|
Tree []treeEntry `json:"tree"`
|
|
Truncated bool `json:"truncated"`
|
|
}
|
|
|
|
type RepoVisibility string
|
|
|
|
const (
|
|
RepoVisibilityPublic RepoVisibility = "public"
|
|
RepoVisibilityPrivate RepoVisibility = "private"
|
|
RepoVisibilityInternal RepoVisibility = "internal"
|
|
)
|
|
|
|
func parseRepoVisibility(s string) (RepoVisibility, error) {
|
|
switch s {
|
|
case "public":
|
|
return RepoVisibilityPublic, nil
|
|
case "private":
|
|
return RepoVisibilityPrivate, nil
|
|
case "internal":
|
|
return RepoVisibilityInternal, nil
|
|
default:
|
|
return "", fmt.Errorf("unknown repository visibility: %q", s)
|
|
}
|
|
}
|
|
|
|
// FetchRepoVisibility returns the repository visibility: "public", "private", or "internal".
|
|
func FetchRepoVisibility(client *api.Client, host, owner, repo string) (RepoVisibility, error) {
|
|
apiPath := fmt.Sprintf("repos/%s/%s", url.PathEscape(owner), url.PathEscape(repo))
|
|
var resp struct {
|
|
Visibility string `json:"visibility"`
|
|
}
|
|
if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil {
|
|
return "", err
|
|
}
|
|
return parseRepoVisibility(resp.Visibility)
|
|
}
|
|
|
|
// ResolveRef determines the git ref to use for a given owner/repo.
|
|
// Priority: explicit version > latest release tag > default branch.
|
|
func ResolveRef(client *api.Client, host, owner, repo, version string) (*ResolvedRef, error) {
|
|
if version != "" {
|
|
return resolveExplicitRef(client, host, owner, repo, version)
|
|
}
|
|
ref, err := resolveLatestRelease(client, host, owner, repo)
|
|
if err == nil {
|
|
return ref, nil
|
|
}
|
|
// Only fall back to the default branch when the repository genuinely
|
|
// has no releases (404) or the latest release has no tag. Any other
|
|
// API error (403, 500, network failure, …) is surfaced immediately
|
|
// so it cannot silently mask problems and cause an unexpected ref to
|
|
// be used.
|
|
var nre *noReleasesError
|
|
if !errors.As(err, &nre) {
|
|
return nil, err
|
|
}
|
|
return resolveDefaultBranch(client, host, owner, repo)
|
|
}
|
|
|
|
// resolveExplicitRef resolves a user-supplied version string. It supports:
|
|
// - fully qualified refs: "refs/tags/v1.0" or "refs/heads/main"
|
|
// - short names: tried as branch first, then tag, then commit SHA
|
|
// - bare SHAs: resolved as commit SHA
|
|
//
|
|
// When a short name matches both a branch and a tag, the branch wins.
|
|
// The returned Ref is always a fully qualified ref (refs/heads/* or refs/tags/*)
|
|
// unless the input resolves to a bare commit SHA.
|
|
func resolveExplicitRef(client *api.Client, host, owner, repo, ref string) (*ResolvedRef, error) {
|
|
// Handle fully-qualified refs: resolve directly without ambiguity.
|
|
if after, ok := strings.CutPrefix(ref, "refs/tags/"); ok {
|
|
return resolveTagRef(client, host, owner, repo, after)
|
|
}
|
|
if after, ok := strings.CutPrefix(ref, "refs/heads/"); ok {
|
|
return resolveBranchRef(client, host, owner, repo, after)
|
|
}
|
|
|
|
// Short name: try branch first, then tag, then commit SHA.
|
|
// Only fall through on 404 (not found); surface other errors
|
|
// (403, 500, network) immediately to avoid masking real failures.
|
|
if resolved, err := resolveBranchRef(client, host, owner, repo, ref); err == nil {
|
|
return resolved, nil
|
|
} else if !isNotFound(err) {
|
|
return nil, err
|
|
}
|
|
if resolved, err := resolveTagRef(client, host, owner, repo, ref); err == nil {
|
|
return resolved, nil
|
|
} else if !isNotFound(err) {
|
|
return nil, err
|
|
}
|
|
|
|
commitPath := fmt.Sprintf("repos/%s/%s/commits/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(ref))
|
|
var commitResp struct {
|
|
SHA string `json:"sha"`
|
|
}
|
|
if err := client.REST(host, "GET", commitPath, nil, &commitResp); err == nil {
|
|
return &ResolvedRef{Ref: commitResp.SHA, SHA: commitResp.SHA}, nil
|
|
} else if !isNotFound(err) {
|
|
return nil, err
|
|
}
|
|
|
|
return nil, fmt.Errorf("ref %q not found as branch, tag, or commit in %s/%s", ref, owner, repo)
|
|
}
|
|
|
|
// resolveTagRef looks up a tag by short name and returns a fully qualified ref.
|
|
// For annotated tags, the tag object is dereferenced to obtain the commit SHA.
|
|
func resolveTagRef(client *api.Client, host, owner, repo, tag string) (*ResolvedRef, error) {
|
|
tagPath := fmt.Sprintf("repos/%s/%s/git/ref/tags/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(tag))
|
|
var refResp struct {
|
|
Object struct {
|
|
SHA string `json:"sha"`
|
|
Type string `json:"type"`
|
|
} `json:"object"`
|
|
}
|
|
if err := client.REST(host, "GET", tagPath, nil, &refResp); err != nil {
|
|
return nil, fmt.Errorf("tag %q not found in %s/%s: %w", tag, owner, repo, err)
|
|
}
|
|
sha := refResp.Object.SHA
|
|
if refResp.Object.Type == "tag" {
|
|
derefPath := fmt.Sprintf("repos/%s/%s/git/tags/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha))
|
|
var tagResp struct {
|
|
Object struct {
|
|
SHA string `json:"sha"`
|
|
} `json:"object"`
|
|
}
|
|
if err := client.REST(host, "GET", derefPath, nil, &tagResp); err != nil {
|
|
return nil, fmt.Errorf("could not dereference annotated tag %q: %w", tag, err)
|
|
}
|
|
sha = tagResp.Object.SHA
|
|
}
|
|
return &ResolvedRef{Ref: "refs/tags/" + tag, SHA: sha}, nil
|
|
}
|
|
|
|
// resolveBranchRef looks up a branch by short name and returns a fully qualified ref.
|
|
func resolveBranchRef(client *api.Client, host, owner, repo, branch string) (*ResolvedRef, error) {
|
|
refPath := fmt.Sprintf("repos/%s/%s/git/ref/heads/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(branch))
|
|
var refResp struct {
|
|
Object struct {
|
|
SHA string `json:"sha"`
|
|
} `json:"object"`
|
|
}
|
|
if err := client.REST(host, "GET", refPath, nil, &refResp); err != nil {
|
|
return nil, fmt.Errorf("branch %q not found in %s/%s: %w", branch, owner, repo, err)
|
|
}
|
|
return &ResolvedRef{Ref: "refs/heads/" + branch, SHA: refResp.Object.SHA}, nil
|
|
}
|
|
|
|
// isNotFound returns true if the error is an HTTP 404 response.
|
|
func isNotFound(err error) bool {
|
|
var httpErr api.HTTPError
|
|
return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound
|
|
}
|
|
|
|
// noReleasesError signals that the repository has no usable releases,
|
|
// which is the only case where ResolveRef should fall back to the
|
|
// default branch.
|
|
type noReleasesError struct {
|
|
reason string
|
|
}
|
|
|
|
func (e *noReleasesError) Error() string { return e.reason }
|
|
|
|
func resolveLatestRelease(client *api.Client, host, owner, repo string) (*ResolvedRef, error) {
|
|
apiPath := fmt.Sprintf("repos/%s/%s/releases/latest", url.PathEscape(owner), url.PathEscape(repo))
|
|
var resp struct {
|
|
TagName string `json:"tag_name"`
|
|
}
|
|
if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil {
|
|
// A 404 means the repository has no releases. This is the
|
|
// only case where falling back to the default branch is safe.
|
|
// Any other HTTP error (403, 500, …) or network failure is
|
|
// returned as-is so ResolveRef surfaces it rather than
|
|
// silently falling back.
|
|
if isNotFound(err) {
|
|
return nil, &noReleasesError{reason: fmt.Sprintf("no releases found for %s/%s", owner, repo)}
|
|
}
|
|
return nil, fmt.Errorf("could not fetch latest release: %w", err)
|
|
}
|
|
if resp.TagName == "" {
|
|
return nil, &noReleasesError{reason: "latest release has no tag"}
|
|
}
|
|
return resolveTagRef(client, host, owner, repo, resp.TagName)
|
|
}
|
|
|
|
func resolveDefaultBranch(client *api.Client, host, owner, repo string) (*ResolvedRef, error) {
|
|
apiPath := fmt.Sprintf("repos/%s/%s", url.PathEscape(owner), url.PathEscape(repo))
|
|
var resp struct {
|
|
DefaultBranch string `json:"default_branch"`
|
|
}
|
|
if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil {
|
|
return nil, fmt.Errorf("could not determine default branch: %w", err)
|
|
}
|
|
branch := resp.DefaultBranch
|
|
if branch == "" {
|
|
return nil, fmt.Errorf("could not determine default branch for %s/%s", owner, repo)
|
|
}
|
|
return resolveBranchRef(client, host, owner, repo, branch)
|
|
}
|
|
|
|
// skillMatch represents a matched SKILL.md file and its convention.
|
|
type skillMatch struct {
|
|
entry treeEntry
|
|
name string
|
|
namespace string
|
|
skillDir string
|
|
convention string
|
|
}
|
|
|
|
// MatchesSkillPath checks if a file path matches any known skill convention
|
|
// and returns the skill name. Returns empty string if the path doesn't match.
|
|
func MatchesSkillPath(filePath string) string {
|
|
m := matchSkillConventions(treeEntry{Path: filePath})
|
|
if m == nil {
|
|
return ""
|
|
}
|
|
return m.name
|
|
}
|
|
|
|
// MatchSkillPath checks if a file path matches any known skill convention
|
|
// and returns the skill name and namespace. Returns empty strings if the
|
|
// path doesn't match. The namespace is non-empty for namespaced skills
|
|
// (e.g. skills/author/name/SKILL.md) and plugin skills.
|
|
func MatchSkillPath(filePath string) (name, namespace string) {
|
|
m := matchSkillConventions(treeEntry{Path: filePath})
|
|
if m == nil {
|
|
return "", ""
|
|
}
|
|
return m.name, m.namespace
|
|
}
|
|
|
|
// matchSkillConventions checks if a blob path matches any known skill convention.
|
|
func matchSkillConventions(entry treeEntry) *skillMatch {
|
|
if path.Base(entry.Path) != "SKILL.md" {
|
|
return nil
|
|
}
|
|
|
|
dir := path.Dir(entry.Path)
|
|
parentDir := path.Dir(dir)
|
|
skillName := path.Base(dir)
|
|
|
|
if !validateName(skillName) {
|
|
return nil
|
|
}
|
|
|
|
if parentDir == "skills" {
|
|
return &skillMatch{entry: entry, name: skillName, skillDir: dir, convention: "skills"}
|
|
}
|
|
|
|
grandparentDir := path.Dir(parentDir)
|
|
if grandparentDir == "skills" {
|
|
namespace := path.Base(parentDir)
|
|
if !validateName(namespace) {
|
|
return nil
|
|
}
|
|
return &skillMatch{entry: entry, name: skillName, namespace: namespace, skillDir: dir, convention: "skills-namespaced"}
|
|
}
|
|
|
|
if path.Base(parentDir) == "skills" && path.Dir(grandparentDir) == "plugins" {
|
|
namespace := path.Base(grandparentDir)
|
|
if !validateName(namespace) {
|
|
return nil
|
|
}
|
|
return &skillMatch{entry: entry, name: skillName, namespace: namespace, skillDir: dir, convention: "plugins"}
|
|
}
|
|
|
|
// Deeply nested skills/ directory: <prefix>/skills/<name>/SKILL.md
|
|
// Matches skills/ at any depth, not just at the repository root.
|
|
// Exclude paths with dot-prefixed segments (handled by
|
|
// matchHiddenDirConventions) and paths under a plugins/ directory
|
|
// (handled by the plugins convention above).
|
|
if path.Base(parentDir) == "skills" && !hasHiddenSegment(entry.Path) && !hasPluginsAncestor(entry.Path) {
|
|
return &skillMatch{entry: entry, name: skillName, skillDir: dir, convention: "skills"}
|
|
}
|
|
|
|
// Deeply nested namespaced: <prefix>/skills/<namespace>/<name>/SKILL.md
|
|
if path.Base(grandparentDir) == "skills" && !hasHiddenSegment(entry.Path) && !hasPluginsAncestor(entry.Path) {
|
|
namespace := path.Base(parentDir)
|
|
if !validateName(namespace) {
|
|
return nil
|
|
}
|
|
return &skillMatch{entry: entry, name: skillName, namespace: namespace, skillDir: dir, convention: "skills-namespaced"}
|
|
}
|
|
|
|
if parentDir == "." && skillName != "skills" && skillName != "plugins" && !strings.HasPrefix(skillName, ".") {
|
|
return &skillMatch{entry: entry, name: skillName, skillDir: dir, convention: "root"}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// matchHiddenDirConventions checks if a blob path matches a skill convention
|
|
// under a hidden (dot-prefixed) root directory. These patterns mirror the
|
|
// standard skills/ conventions but rooted under .{host}/skills/:
|
|
//
|
|
// - .{host}/skills/*/SKILL.md -> "hidden-dir"
|
|
// - .{host}/skills/{scope}/*/SKILL.md -> "hidden-dir-namespaced"
|
|
func matchHiddenDirConventions(entry treeEntry) *skillMatch {
|
|
if path.Base(entry.Path) != "SKILL.md" {
|
|
return nil
|
|
}
|
|
|
|
// .{host}/skills/*
|
|
// .{host}/skills/{scope}/*
|
|
dir := path.Dir(entry.Path)
|
|
skillName := path.Base(dir)
|
|
|
|
if !validateName(skillName) {
|
|
return nil
|
|
}
|
|
|
|
// .{host}/skills
|
|
// .{host}/skills/{scope}
|
|
parentDir := path.Dir(dir)
|
|
|
|
// .{host}/skills/*/SKILL.md
|
|
if path.Base(parentDir) == "skills" {
|
|
hiddenRoot := path.Dir(parentDir)
|
|
if path.Dir(hiddenRoot) == "." && strings.HasPrefix(hiddenRoot, ".") {
|
|
return &skillMatch{entry: entry, name: skillName, skillDir: dir, convention: "hidden-dir"}
|
|
}
|
|
}
|
|
|
|
// .{host}/skills/{scope}/*/SKILL.md
|
|
grandparentDir := path.Dir(parentDir)
|
|
if path.Base(grandparentDir) == "skills" {
|
|
hiddenRoot := path.Dir(grandparentDir)
|
|
if path.Dir(hiddenRoot) == "." && strings.HasPrefix(hiddenRoot, ".") {
|
|
namespace := path.Base(parentDir)
|
|
if !validateName(namespace) {
|
|
return nil
|
|
}
|
|
return &skillMatch{entry: entry, name: skillName, namespace: namespace, skillDir: dir, convention: "hidden-dir-namespaced"}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// DiscoverOptions controls optional discovery behaviors.
|
|
type DiscoverOptions struct {
|
|
}
|
|
|
|
// DiscoverSkills finds all non-hidden-dir skills in a repository at the given
|
|
// commit SHA. Hidden-dir skills are excluded; use DiscoverSkillsWithOptions to
|
|
// retrieve all skills including those in hidden directories.
|
|
func DiscoverSkills(client *api.Client, host, owner, repo, commitSHA string) ([]Skill, error) {
|
|
all, err := DiscoverSkillsWithOptions(client, host, owner, repo, commitSHA, DiscoverOptions{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var skills []Skill
|
|
for _, s := range all {
|
|
if !s.IsHiddenDirConvention() {
|
|
skills = append(skills, s)
|
|
}
|
|
}
|
|
if len(skills) == 0 {
|
|
return nil, fmt.Errorf(
|
|
"no skills found in %s/%s\n"+
|
|
" Expected skills in skills/*/SKILL.md, skills/{scope}/*/SKILL.md,\n"+
|
|
" */SKILL.md, or plugins/*/skills/*/SKILL.md\n"+
|
|
" This repository may be a curated list rather than a skills publisher",
|
|
owner, repo,
|
|
)
|
|
}
|
|
return skills, nil
|
|
}
|
|
|
|
// DiscoverSkillsWithOptions finds all skills in a repository at the given
|
|
// commit SHA, with configurable discovery behavior.
|
|
func DiscoverSkillsWithOptions(client *api.Client, host, owner, repo, commitSHA string, opts DiscoverOptions) ([]Skill, error) {
|
|
apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s?recursive=true", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(commitSHA))
|
|
var tree treeResponse
|
|
if err := client.REST(host, "GET", apiPath, nil, &tree); err != nil {
|
|
return nil, fmt.Errorf("could not fetch repository tree: %w", err)
|
|
}
|
|
|
|
if tree.Truncated {
|
|
return nil, &TreeTooLargeError{Owner: owner, Repo: repo}
|
|
}
|
|
|
|
treeSHAs := make(map[string]string)
|
|
for _, entry := range tree.Tree {
|
|
if entry.Type == "tree" {
|
|
treeSHAs[entry.Path] = entry.SHA
|
|
}
|
|
}
|
|
|
|
seen := make(map[string]bool)
|
|
var matches []skillMatch
|
|
for _, entry := range tree.Tree {
|
|
if entry.Type != "blob" {
|
|
continue
|
|
}
|
|
m := matchSkillConventions(entry)
|
|
if m == nil {
|
|
m = matchHiddenDirConventions(entry)
|
|
}
|
|
if m == nil {
|
|
continue
|
|
}
|
|
if seen[m.skillDir] {
|
|
continue
|
|
}
|
|
seen[m.skillDir] = true
|
|
matches = append(matches, *m)
|
|
}
|
|
|
|
if len(matches) == 0 {
|
|
return nil, fmt.Errorf(
|
|
"no skills found in %s/%s\n"+
|
|
" Expected skills in skills/*/SKILL.md, skills/{scope}/*/SKILL.md,\n"+
|
|
" {prefix}/skills/*/SKILL.md, {prefix}/skills/{scope}/*/SKILL.md,\n"+
|
|
" */SKILL.md, or plugins/*/skills/*/SKILL.md\n"+
|
|
" This repository may be a curated list rather than a skills publisher",
|
|
owner, repo,
|
|
)
|
|
}
|
|
|
|
var skills []Skill
|
|
for _, m := range matches {
|
|
skills = append(skills, Skill{
|
|
Name: m.name,
|
|
Namespace: m.namespace,
|
|
Path: m.skillDir,
|
|
BlobSHA: m.entry.SHA,
|
|
TreeSHA: treeSHAs[m.skillDir],
|
|
Convention: m.convention,
|
|
})
|
|
}
|
|
|
|
sort.SliceStable(skills, func(i, j int) bool {
|
|
return skills[i].DisplayName() < skills[j].DisplayName()
|
|
})
|
|
|
|
return skills, nil
|
|
}
|
|
|
|
// fetchDescription fetches and parses the frontmatter description for a skill.
|
|
func fetchDescription(client *api.Client, host, owner, repo string, skill *Skill) string {
|
|
if skill.BlobSHA == "" {
|
|
return ""
|
|
}
|
|
content, err := FetchBlob(client, host, owner, repo, skill.BlobSHA)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
result, err := frontmatter.Parse(content)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return result.Metadata.Description
|
|
}
|
|
|
|
// FetchDescriptionsConcurrent fetches descriptions with bounded concurrency.
|
|
func FetchDescriptionsConcurrent(client *api.Client, host, owner, repo string, skills []Skill, onProgress func(done, total int)) {
|
|
total := 0
|
|
for _, s := range skills {
|
|
if s.Description == "" {
|
|
total++
|
|
}
|
|
}
|
|
if total == 0 {
|
|
return
|
|
}
|
|
|
|
const maxWorkers = 10
|
|
var wg sync.WaitGroup
|
|
var done atomic.Int32
|
|
|
|
jobs := make(chan *Skill)
|
|
|
|
workers := min(maxWorkers, total)
|
|
for range workers {
|
|
wg.Go(func() {
|
|
for s := range jobs {
|
|
s.Description = fetchDescription(client, host, owner, repo, s)
|
|
|
|
d := int(done.Add(1))
|
|
if onProgress != nil {
|
|
onProgress(d, total)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
for i := range skills {
|
|
if skills[i].Description == "" {
|
|
jobs <- &skills[i]
|
|
}
|
|
}
|
|
close(jobs)
|
|
wg.Wait()
|
|
}
|
|
|
|
// DiscoverSkillByPath looks up a single skill by its exact path in the repository.
|
|
func DiscoverSkillByPath(client *api.Client, host, owner, repo, commitSHA, skillPath string) (*Skill, error) {
|
|
skillPath = strings.TrimSuffix(skillPath, "/SKILL.md")
|
|
skillPath = strings.TrimSuffix(skillPath, "/")
|
|
|
|
skillName := path.Base(skillPath)
|
|
if !validateName(skillName) {
|
|
return nil, fmt.Errorf("invalid skill name %q", skillName)
|
|
}
|
|
|
|
parentPath := path.Dir(skillPath)
|
|
apiPath := fmt.Sprintf("repos/%s/%s/contents/%s?ref=%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(parentPath), commitSHA)
|
|
|
|
var contents []struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
SHA string `json:"sha"`
|
|
Type string `json:"type"`
|
|
}
|
|
if err := client.REST(host, "GET", apiPath, nil, &contents); err != nil {
|
|
return nil, fmt.Errorf("path %q not found in %s/%s: %w", parentPath, owner, repo, err)
|
|
}
|
|
|
|
var treeSHA string
|
|
for _, entry := range contents {
|
|
if entry.Name == skillName && entry.Type == "dir" {
|
|
treeSHA = entry.SHA
|
|
break
|
|
}
|
|
}
|
|
if treeSHA == "" {
|
|
return nil, fmt.Errorf("skill directory %q not found in %s/%s", skillPath, owner, repo)
|
|
}
|
|
|
|
skillTreePath := fmt.Sprintf("repos/%s/%s/git/trees/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(treeSHA))
|
|
var skillTree treeResponse
|
|
if err := client.REST(host, "GET", skillTreePath, nil, &skillTree); err != nil {
|
|
return nil, fmt.Errorf("could not read skill directory: %w", err)
|
|
}
|
|
|
|
var blobSHA string
|
|
for _, entry := range skillTree.Tree {
|
|
if entry.Path == "SKILL.md" && entry.Type == "blob" {
|
|
blobSHA = entry.SHA
|
|
break
|
|
}
|
|
}
|
|
if blobSHA == "" {
|
|
return nil, fmt.Errorf("no SKILL.md found in %s", skillPath)
|
|
}
|
|
|
|
var namespace, convention string
|
|
parts := strings.Split(skillPath, "/")
|
|
for i, p := range parts {
|
|
if p != "skills" {
|
|
continue
|
|
}
|
|
|
|
// Plugin convention: .../plugins/<ns>/skills/<name>
|
|
if i >= 2 && parts[i-2] == "plugins" {
|
|
namespace = parts[i-1]
|
|
convention = "plugins"
|
|
break
|
|
}
|
|
|
|
// Namespaced skill convention: .../skills/<ns>/<name>
|
|
afterSkills := parts[i+1:]
|
|
if len(afterSkills) >= 2 {
|
|
namespace = afterSkills[0]
|
|
}
|
|
break
|
|
}
|
|
|
|
skill := &Skill{
|
|
Name: skillName,
|
|
Namespace: namespace,
|
|
Convention: convention,
|
|
Path: skillPath,
|
|
BlobSHA: blobSHA,
|
|
TreeSHA: treeSHA,
|
|
}
|
|
|
|
skill.Description = fetchDescription(client, host, owner, repo, skill)
|
|
|
|
return skill, nil
|
|
}
|
|
|
|
// DiscoverSkillFiles returns all file paths belonging to a skill directory
|
|
// by fetching the skill's subtree directly using its tree SHA.
|
|
func DiscoverSkillFiles(client *api.Client, host, owner, repo, treeSHA, skillPath string) ([]SkillFile, error) {
|
|
apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s?recursive=true", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(treeSHA))
|
|
var tree treeResponse
|
|
if err := client.REST(host, "GET", apiPath, nil, &tree); err != nil {
|
|
return nil, fmt.Errorf("could not fetch skill tree: %w", err)
|
|
}
|
|
|
|
if tree.Truncated {
|
|
// Recursive fetch was truncated. Fall back to walking subtrees individually.
|
|
return walkTree(client, host, owner, repo, treeSHA, skillPath, 0)
|
|
}
|
|
|
|
var files []SkillFile
|
|
for _, entry := range tree.Tree {
|
|
if entry.Type == "blob" {
|
|
files = append(files, SkillFile{
|
|
Path: skillPath + "/" + entry.Path,
|
|
SHA: entry.SHA,
|
|
Size: entry.Size,
|
|
})
|
|
}
|
|
}
|
|
|
|
return files, nil
|
|
}
|
|
|
|
// ListSkillFiles returns all files in a skill directory as public SkillFile
|
|
// structs with paths relative to the skill root.
|
|
func ListSkillFiles(client *api.Client, host, owner, repo, treeSHA string) ([]SkillFile, error) {
|
|
apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s?recursive=true", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(treeSHA))
|
|
var tree treeResponse
|
|
if err := client.REST(host, "GET", apiPath, nil, &tree); err != nil {
|
|
return nil, fmt.Errorf("could not fetch skill tree: %w", err)
|
|
}
|
|
|
|
if tree.Truncated {
|
|
// Fall back to non-recursive traversal when the tree is too large.
|
|
return walkTree(client, host, owner, repo, treeSHA, "", 0)
|
|
}
|
|
|
|
var files []SkillFile
|
|
for _, entry := range tree.Tree {
|
|
if entry.Type == "blob" {
|
|
files = append(files, SkillFile{
|
|
Path: entry.Path,
|
|
SHA: entry.SHA,
|
|
Size: entry.Size,
|
|
})
|
|
}
|
|
}
|
|
return files, nil
|
|
}
|
|
|
|
// maxTreeDepth bounds the recursion in walkTree to prevent unbounded
|
|
// API calls on deeply nested repositories.
|
|
const maxTreeDepth = 20
|
|
|
|
// walkTree enumerates files by fetching each tree level individually,
|
|
// avoiding the truncation limit of the recursive tree API. Recursion
|
|
// depth is bounded by maxTreeDepth to prevent unbounded API calls.
|
|
func walkTree(client *api.Client, host, owner, repo, sha, prefix string, depth int) ([]SkillFile, error) {
|
|
if depth > maxTreeDepth {
|
|
return nil, fmt.Errorf("tree depth exceeds %d levels at %s", maxTreeDepth, prefix)
|
|
}
|
|
apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha))
|
|
var tree treeResponse
|
|
if err := client.REST(host, "GET", apiPath, nil, &tree); err != nil {
|
|
return nil, fmt.Errorf("could not fetch tree %s: %w", prefix, err)
|
|
}
|
|
|
|
var files []SkillFile
|
|
for _, entry := range tree.Tree {
|
|
entryPath := entry.Path
|
|
if prefix != "" {
|
|
entryPath = prefix + "/" + entry.Path
|
|
}
|
|
switch entry.Type {
|
|
case "blob":
|
|
files = append(files, SkillFile{Path: entryPath, SHA: entry.SHA, Size: entry.Size})
|
|
case "tree":
|
|
sub, err := walkTree(client, host, owner, repo, entry.SHA, entryPath, depth+1)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
files = append(files, sub...)
|
|
}
|
|
}
|
|
return files, nil
|
|
}
|
|
|
|
// FetchBlob retrieves the content of a blob by SHA.
|
|
func FetchBlob(client *api.Client, host, owner, repo, sha string) (string, error) {
|
|
apiPath := fmt.Sprintf("repos/%s/%s/git/blobs/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha))
|
|
var resp struct {
|
|
SHA string `json:"sha"`
|
|
Content string `json:"content"`
|
|
Encoding string `json:"encoding"`
|
|
}
|
|
if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil {
|
|
return "", fmt.Errorf("could not fetch blob: %w", err)
|
|
}
|
|
|
|
if resp.Encoding != "base64" {
|
|
return "", fmt.Errorf("unexpected blob encoding: %s", resp.Encoding)
|
|
}
|
|
|
|
// GitHub API returns base64 with embedded newlines; use the StdEncoding
|
|
// decoder via a reader to handle them transparently.
|
|
decoded, err := io.ReadAll(base64.NewDecoder(base64.StdEncoding, strings.NewReader(resp.Content)))
|
|
if err != nil {
|
|
return "", fmt.Errorf("could not decode blob content: %w", err)
|
|
}
|
|
|
|
return string(decoded), nil
|
|
}
|
|
|
|
// DiscoverLocalSkills finds non-hidden-dir skills in a local directory using
|
|
// the same conventions as remote discovery. Hidden-dir skills are excluded; use
|
|
// DiscoverLocalSkillsWithOptions to retrieve all skills including those in
|
|
// hidden directories.
|
|
func DiscoverLocalSkills(dir string) ([]Skill, error) {
|
|
all, err := DiscoverLocalSkillsWithOptions(dir, DiscoverOptions{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var skills []Skill
|
|
for _, s := range all {
|
|
if !s.IsHiddenDirConvention() {
|
|
skills = append(skills, s)
|
|
}
|
|
}
|
|
if len(skills) == 0 {
|
|
return nil, fmt.Errorf(
|
|
"no skills found in %s\n"+
|
|
" Expected SKILL.md in the directory, or skills in skills/*/SKILL.md,\n"+
|
|
" skills/{scope}/*/SKILL.md, */SKILL.md, or plugins/*/skills/*/SKILL.md",
|
|
dir,
|
|
)
|
|
}
|
|
return skills, nil
|
|
}
|
|
|
|
// DiscoverLocalSkillsWithOptions finds skills in a local directory using the
|
|
// same conventions as remote discovery, with configurable discovery behavior.
|
|
func DiscoverLocalSkillsWithOptions(dir string, opts DiscoverOptions) ([]Skill, error) {
|
|
absDir, err := filepath.Abs(dir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not resolve path: %w", err)
|
|
}
|
|
|
|
info, err := os.Stat(absDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not access %s: %w", dir, err)
|
|
}
|
|
if !info.IsDir() {
|
|
return nil, fmt.Errorf("%s is not a directory", dir)
|
|
}
|
|
|
|
if _, err := os.Stat(filepath.Join(absDir, "SKILL.md")); err == nil {
|
|
skill, err := localSkillFromDir(absDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
skill.Path = "."
|
|
return []Skill{*skill}, nil
|
|
}
|
|
|
|
var skills []Skill
|
|
seen := make(map[string]bool)
|
|
|
|
err = filepath.Walk(absDir, func(p string, info os.FileInfo, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
// Skip symlinks to avoid following links outside the source tree.
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return nil
|
|
}
|
|
if info.IsDir() || info.Name() != "SKILL.md" {
|
|
return nil
|
|
}
|
|
|
|
relPath, relErr := filepath.Rel(absDir, p)
|
|
if relErr != nil {
|
|
return relErr
|
|
}
|
|
relPath = filepath.ToSlash(relPath)
|
|
|
|
entry := treeEntry{Path: relPath, Type: "blob"}
|
|
m := matchSkillConventions(entry)
|
|
if m == nil {
|
|
m = matchHiddenDirConventions(entry)
|
|
}
|
|
if m == nil {
|
|
return nil
|
|
}
|
|
if seen[m.skillDir] {
|
|
return nil
|
|
}
|
|
seen[m.skillDir] = true
|
|
|
|
skill, skillErr := localSkillFromDir(filepath.Join(absDir, filepath.FromSlash(m.skillDir)))
|
|
if skillErr != nil {
|
|
return nil //nolint:nilerr // intentionally skip files that aren't valid skills
|
|
}
|
|
skill.Path = m.skillDir
|
|
skill.Namespace = m.namespace
|
|
skill.Convention = m.convention
|
|
skills = append(skills, *skill)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not walk directory: %w", err)
|
|
}
|
|
|
|
if len(skills) == 0 {
|
|
return nil, fmt.Errorf(
|
|
"no skills found in %s\n"+
|
|
" Expected SKILL.md in the directory, or skills in skills/*/SKILL.md,\n"+
|
|
" skills/{scope}/*/SKILL.md, {prefix}/skills/*/SKILL.md,\n"+
|
|
" {prefix}/skills/{scope}/*/SKILL.md, */SKILL.md, or\n"+
|
|
" plugins/*/skills/*/SKILL.md",
|
|
dir,
|
|
)
|
|
}
|
|
|
|
return skills, nil
|
|
}
|
|
|
|
func localSkillFromDir(dir string) (*Skill, error) {
|
|
skillFile := filepath.Join(dir, "SKILL.md")
|
|
data, err := os.ReadFile(skillFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not read %s: %w", skillFile, err)
|
|
}
|
|
|
|
name := filepath.Base(dir)
|
|
var description string
|
|
|
|
result, parseErr := frontmatter.Parse(string(data))
|
|
if parseErr == nil {
|
|
if result.Metadata.Name != "" {
|
|
name = result.Metadata.Name
|
|
}
|
|
description = result.Metadata.Description
|
|
}
|
|
|
|
if !validateName(name) {
|
|
return nil, fmt.Errorf("invalid skill name %q in %s", name, dir)
|
|
}
|
|
|
|
return &Skill{
|
|
Name: name,
|
|
Description: description,
|
|
Path: filepath.Base(dir),
|
|
}, nil
|
|
}
|
|
|
|
// validateName checks if a skill name is safe for use (filesystem-safe).
|
|
func validateName(name string) bool {
|
|
if len(name) == 0 || len(name) > 64 {
|
|
return false
|
|
}
|
|
if strings.Contains(name, "/") || strings.Contains(name, "..") {
|
|
return false
|
|
}
|
|
return safeNamePattern.MatchString(name)
|
|
}
|
|
|
|
// hasHiddenSegment reports whether any path component starts with a dot.
|
|
func hasHiddenSegment(p string) bool {
|
|
for _, seg := range strings.Split(p, "/") {
|
|
if strings.HasPrefix(seg, ".") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// hasPluginsAncestor reports whether any path component is "plugins".
|
|
func hasPluginsAncestor(p string) bool {
|
|
for _, seg := range strings.Split(p, "/") {
|
|
if seg == "plugins" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsSpecCompliant checks if a skill name matches the strict agentskills.io spec.
|
|
func IsSpecCompliant(name string) bool {
|
|
if len(name) == 0 || len(name) > 64 {
|
|
return false
|
|
}
|
|
if strings.Contains(name, "--") {
|
|
return false
|
|
}
|
|
return specNamePattern.MatchString(name)
|
|
}
|