From e57fb436fa23106014e52774596a09ee834e2feb Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Mon, 30 Mar 2026 17:26:41 +0100 Subject: [PATCH 01/27] add skills command scaffold --- internal/skills/collisions.go | 55 ++ internal/skills/discovery/discovery.go | 769 ++++++++++++++++++ internal/skills/discovery/discovery_test.go | 109 +++ internal/skills/frontmatter/frontmatter.go | 148 ++++ .../skills/frontmatter/frontmatter_test.go | 178 ++++ internal/skills/gitclient/gitclient.go | 149 ++++ internal/skills/gitclient/gitclient_test.go | 49 ++ internal/skills/hosts/hosts.go | 175 ++++ internal/skills/hosts/hosts_test.go | 113 +++ internal/skills/installer/installer.go | 296 +++++++ internal/skills/lockfile/lockfile.go | 165 ++++ 11 files changed, 2206 insertions(+) create mode 100644 internal/skills/collisions.go create mode 100644 internal/skills/discovery/discovery.go create mode 100644 internal/skills/discovery/discovery_test.go create mode 100644 internal/skills/frontmatter/frontmatter.go create mode 100644 internal/skills/frontmatter/frontmatter_test.go create mode 100644 internal/skills/gitclient/gitclient.go create mode 100644 internal/skills/gitclient/gitclient_test.go create mode 100644 internal/skills/hosts/hosts.go create mode 100644 internal/skills/hosts/hosts_test.go create mode 100644 internal/skills/installer/installer.go create mode 100644 internal/skills/lockfile/lockfile.go diff --git a/internal/skills/collisions.go b/internal/skills/collisions.go new file mode 100644 index 000000000..87e4705c9 --- /dev/null +++ b/internal/skills/collisions.go @@ -0,0 +1,55 @@ +package skills + +import ( + "fmt" + "sort" + "strings" + + "github.com/cli/cli/v2/internal/skills/discovery" +) + +// NameCollision represents a group of skills that share the same InstallName +// and would overwrite each other when installed to the same directory. +type NameCollision struct { + Name string // the conflicting install name (may include namespace prefix) + DisplayNames []string // display names of each conflicting skill +} + +// FindNameCollisions detects skills that share the same InstallName and returns a +// sorted slice of collisions. Callers decide how to present the conflict to +// the user (different flows need different error messages). +func FindNameCollisions(skills []discovery.Skill) []NameCollision { + byName := make(map[string][]discovery.Skill) + for _, s := range skills { + byName[s.InstallName()] = append(byName[s.InstallName()], s) + } + + var collisions []NameCollision + for name, group := range byName { + if len(group) <= 1 { + continue + } + names := make([]string, len(group)) + for i, s := range group { + names[i] = s.DisplayName() + } + collisions = append(collisions, NameCollision{Name: name, DisplayNames: names}) + } + + sort.Slice(collisions, func(i, j int) bool { + return collisions[i].Name < collisions[j].Name + }) + return collisions +} + +// FormatCollisions builds a human-readable string listing each collision, +// suitable for embedding in an error message. Each collision is formatted as +// "name: display1, display2" and collisions are separated by newlines with +// leading indentation. +func FormatCollisions(collisions []NameCollision) string { + lines := make([]string, len(collisions)) + for i, c := range collisions { + lines[i] = fmt.Sprintf("%s: %s", c.Name, strings.Join(c.DisplayNames, ", ")) + } + return strings.Join(lines, "\n ") +} diff --git a/internal/skills/discovery/discovery.go b/internal/skills/discovery/discovery.go new file mode 100644 index 000000000..e0e881088 --- /dev/null +++ b/internal/skills/discovery/discovery.go @@ -0,0 +1,769 @@ +package discovery + +import ( + "encoding/base64" + "fmt" + "io" + "os" + "path" + "path/filepath" + "regexp" + "strings" + "sync" + + "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])?$`) + +// 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 + 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 +} + +// ResolvedRef contains the resolved git reference and its SHA. +type ResolvedRef struct { + Ref string // tag name, branch name, or SHA + SHA string // commit SHA +} + +// RESTClient is the interface for making GitHub REST API calls. +// It mirrors the subset of api.Client used by discovery. +type RESTClient interface { + // REST performs a REST API call. + // hostname is the GitHub host (e.g. "github.com"). + // method is the HTTP method (e.g. "GET"). + // path is the API path (e.g. "repos/owner/repo/releases/latest"). + // body is the request body (nil for GET). + // data is the response data to unmarshal into. + REST(hostname string, method string, path string, body io.Reader, data interface{}) error +} + +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 blobResponse struct { + SHA string `json:"sha"` + Content string `json:"content"` + Encoding string `json:"encoding"` +} + +type releaseResponse struct { + TagName string `json:"tag_name"` +} + +type repoResponse struct { + DefaultBranch string `json:"default_branch"` +} + +// ResolveRef determines the git ref to use for a given owner/repo. +// Priority: explicit version → latest release tag → default branch. +func ResolveRef(client RESTClient, 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 + } + return resolveDefaultBranch(client, host, owner, repo) +} + +// resolveExplicitRef resolves a user-supplied --pin value. It tries, in order: +// tag → commit SHA. Branches are deliberately excluded because they are mutable +// and pinning to one gives a false sense of reproducibility. +func resolveExplicitRef(client RESTClient, host, owner, repo, ref string) (*ResolvedRef, error) { + tagPath := fmt.Sprintf("repos/%s/%s/git/ref/tags/%s", owner, repo, ref) + 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 { + sha := refResp.Object.SHA + if refResp.Object.Type == "tag" { + derefPath := fmt.Sprintf("repos/%s/%s/git/tags/%s", owner, repo, 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", ref, err) + } + sha = tagResp.Object.SHA + } + return &ResolvedRef{Ref: ref, SHA: sha}, nil + } + + commitPath := fmt.Sprintf("repos/%s/%s/commits/%s", owner, repo, 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 + } + + return nil, fmt.Errorf("ref %q not found as tag or commit in %s/%s", ref, owner, repo) +} + +func resolveLatestRelease(client RESTClient, host, owner, repo string) (*ResolvedRef, error) { + apiPath := fmt.Sprintf("repos/%s/%s/releases/latest", owner, repo) + var release releaseResponse + if err := client.REST(host, "GET", apiPath, nil, &release); err != nil { + return nil, fmt.Errorf("no releases found: %w", err) + } + if release.TagName == "" { + return nil, fmt.Errorf("latest release has no tag") + } + return resolveExplicitRef(client, host, owner, repo, release.TagName) +} + +func resolveDefaultBranch(client RESTClient, host, owner, repo string) (*ResolvedRef, error) { + apiPath := fmt.Sprintf("repos/%s/%s", owner, repo) + var repoResp repoResponse + if err := client.REST(host, "GET", apiPath, nil, &repoResp); err != nil { + return nil, fmt.Errorf("could not determine default branch: %w", err) + } + branch := repoResp.DefaultBranch + if branch == "" { + branch = "main" + } + + refPath := fmt.Sprintf("repos/%s/%s/git/ref/heads/%s", owner, repo, 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("could not resolve branch %q: %w", branch, err) + } + + return &ResolvedRef{Ref: branch, SHA: refResp.Object.SHA}, nil +} + +// 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 +} + +// 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"} + } + + if parentDir == "." && skillName != "skills" && skillName != "plugins" && !strings.HasPrefix(skillName, ".") { + return &skillMatch{entry: entry, name: skillName, skillDir: dir, convention: "root"} + } + + return nil +} + +// DiscoverSkills finds all skills in a repository at the given commit SHA. +func DiscoverSkills(client RESTClient, host, owner, repo, commitSHA string) ([]Skill, error) { + apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s?recursive=true", owner, repo, 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, fmt.Errorf( + "repository tree for %s/%s is too large for full discovery\n"+ + " Use path-based install instead: gh skills install %s/%s skills/", + owner, repo, owner, 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 { + 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"+ + " */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, + }) + } + + return skills, nil +} + +// FetchDescription fetches and parses the frontmatter description for a skill. +func FetchDescription(client RESTClient, 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 +} + +// FetchDescriptions fetches descriptions for a batch of skills. +func FetchDescriptions(client RESTClient, host, owner, repo string, skills []Skill) { + for i := range skills { + if skills[i].Description == "" { + skills[i].Description = FetchDescription(client, host, owner, repo, &skills[i]) + } + } +} + +// FetchDescriptionsConcurrent fetches descriptions with bounded concurrency. +func FetchDescriptionsConcurrent(client RESTClient, 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 + sem := make(chan struct{}, maxWorkers) + var mu sync.Mutex + done := 0 + + var wg sync.WaitGroup + for i := range skills { + if skills[i].Description != "" { + continue + } + wg.Add(1) + go func(idx int) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + desc := FetchDescription(client, host, owner, repo, &skills[idx]) + + mu.Lock() + skills[idx].Description = desc + done++ + d := done + mu.Unlock() + if onProgress != nil { + onProgress(d, total) + } + }(i) + } + wg.Wait() +} + +// DiscoverSkillByPath looks up a single skill by its exact path in the repository. +func DiscoverSkillByPath(client RESTClient, 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", owner, repo, 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", owner, repo, 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 string + parts := strings.Split(skillPath, "/") + if len(parts) >= 3 && parts[0] == "skills" { + namespace = parts[1] + } + + skill := &Skill{ + Name: skillName, + Namespace: namespace, + 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 RESTClient, host, owner, repo, treeSHA, skillPath string) ([]treeEntry, error) { + apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s?recursive=true", owner, repo, 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) + } + + var files []treeEntry + for _, entry := range tree.Tree { + if entry.Type == "blob" { + files = append(files, treeEntry{ + 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 RESTClient, host, owner, repo, treeSHA string) ([]SkillFile, error) { + apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s?recursive=true", owner, repo, 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. + entries, err := walkTree(client, host, owner, repo, treeSHA, "") + if err != nil { + return nil, err + } + var files []SkillFile + for _, e := range entries { + // walkTree prefixes with "/{path}", trim the leading slash. + p := strings.TrimPrefix(e.Path, "/") + files = append(files, SkillFile{Path: p, SHA: e.SHA, Size: e.Size}) + } + return files, nil + } + + 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 +} + +// walkTree enumerates files by fetching each tree level individually, +// avoiding the truncation limit of the recursive tree API. +func walkTree(client RESTClient, host, owner, repo, sha, prefix string) ([]treeEntry, error) { + apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s", owner, repo, 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 []treeEntry + for _, entry := range tree.Tree { + entryPath := prefix + "/" + entry.Path + switch entry.Type { + case "blob": + files = append(files, treeEntry{Path: entryPath, SHA: entry.SHA, Size: entry.Size}) + case "tree": + sub, err := walkTree(client, host, owner, repo, entry.SHA, entryPath) + 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 RESTClient, host, owner, repo, sha string) (string, error) { + apiPath := fmt.Sprintf("repos/%s/%s/git/blobs/%s", owner, repo, sha) + var blob blobResponse + if err := client.REST(host, "GET", apiPath, nil, &blob); err != nil { + return "", fmt.Errorf("could not fetch blob: %w", err) + } + + if blob.Encoding != "base64" { + return "", fmt.Errorf("unexpected blob encoding: %s", blob.Encoding) + } + + // GitHub API returns base64 with embedded newlines; use the lenient + // RawStdEncoding decoder via a reader to handle them transparently. + decoded, err := io.ReadAll(base64.NewDecoder(base64.StdEncoding, strings.NewReader(blob.Content))) + if err != nil { + return "", fmt.Errorf("could not decode blob content: %w", err) + } + + return string(decoded), nil +} + +// DiscoverLocalSkills finds skills in a local directory using the same +// conventions as remote discovery. +func DiscoverLocalSkills(dir string) ([]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 + } + 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 { + 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, */SKILL.md, or 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) +} + +// 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) +} + +// verifyBatchSize controls how many repos are checked per code-search API call. +const verifyBatchSize = 8 + +type codeSearchResponse struct { + Items []codeSearchItem `json:"items"` +} + +type codeSearchItem struct { + Repository codeSearchRepo `json:"repository"` +} + +type codeSearchRepo struct { + FullName string `json:"full_name"` +} + +// VerifySkillRepos filters a list of repository names to only those that +// actually contain SKILL.md files. It uses the GitHub code search API with +// batched repo: qualifiers. +// +// If a verification call fails (e.g. rate limit), repos in that batch are +// kept rather than silently dropped — we fail open. +func VerifySkillRepos(client RESTClient, host string, repos []string) map[string]bool { + verified := make(map[string]bool) + + for i := 0; i < len(repos); i += verifyBatchSize { + end := i + verifyBatchSize + if end > len(repos) { + end = len(repos) + } + batch := repos[i:end] + + var queryParts []string + queryParts = append(queryParts, "filename:SKILL.md") + for _, r := range batch { + queryParts = append(queryParts, "repo:"+r) + } + query := strings.Join(queryParts, "+") + apiPath := fmt.Sprintf("search/code?q=%s&per_page=%d", query, verifyBatchSize*3) + + var resp codeSearchResponse + if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { + // Fail open: if we can't verify, assume all repos in the batch are valid + for _, r := range batch { + verified[r] = true + } + continue + } + + for _, item := range resp.Items { + verified[item.Repository.FullName] = true + } + } + + return verified +} diff --git a/internal/skills/discovery/discovery_test.go b/internal/skills/discovery/discovery_test.go new file mode 100644 index 000000000..b5fe2410d --- /dev/null +++ b/internal/skills/discovery/discovery_test.go @@ -0,0 +1,109 @@ +package discovery + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestInstallName(t *testing.T) { + tests := []struct { + name string + skill Skill + wantName string + }{ + { + name: "plain skill", + skill: Skill{Name: "git-commit"}, + wantName: "git-commit", + }, + { + name: "namespaced skill", + skill: Skill{Name: "xlsx-pro", Namespace: "alice"}, + wantName: "alice/xlsx-pro", + }, + { + name: "plugin skill with namespace", + skill: Skill{Name: "code-review", Namespace: "bob", Convention: "plugins"}, + wantName: "bob/code-review", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.wantName, tt.skill.InstallName()) + }) + } +} + +func TestMatchSkillConventions_PluginNamespace(t *testing.T) { + entry := treeEntry{ + Path: "plugins/bob/skills/code-review/SKILL.md", + Type: "blob", + } + m := matchSkillConventions(entry) + assert.NotNil(t, m) + assert.Equal(t, "code-review", m.name) + assert.Equal(t, "bob", m.namespace) + assert.Equal(t, "plugins", m.convention) +} + +func TestMatchSkillConventions_NamespacedSkill(t *testing.T) { + entry := treeEntry{ + Path: "skills/alice/xlsx-pro/SKILL.md", + Type: "blob", + } + m := matchSkillConventions(entry) + assert.NotNil(t, m) + assert.Equal(t, "xlsx-pro", m.name) + assert.Equal(t, "alice", m.namespace) + assert.Equal(t, "skills-namespaced", m.convention) +} + +func TestMatchSkillConventions_RegularSkill(t *testing.T) { + entry := treeEntry{ + Path: "skills/git-commit/SKILL.md", + Type: "blob", + } + m := matchSkillConventions(entry) + assert.NotNil(t, m) + assert.Equal(t, "git-commit", m.name) + assert.Equal(t, "", m.namespace) + assert.Equal(t, "skills", m.convention) +} + +func TestDuplicatePluginSkills_DifferentAuthors(t *testing.T) { + // Simulates a repo with the same skill name under two different plugin authors. + // Previously this caused a collision error; now each gets a distinct namespace. + entries := []treeEntry{ + {Path: "plugins/author1/skills/azure-diag/SKILL.md", Type: "blob"}, + {Path: "plugins/author2/skills/azure-diag/SKILL.md", Type: "blob"}, + } + + seen := make(map[string]bool) + var matches []skillMatch + for _, e := range entries { + m := matchSkillConventions(e) + if m == nil || seen[m.skillDir] { + continue + } + seen[m.skillDir] = true + matches = append(matches, *m) + } + + assert.Len(t, matches, 2) + assert.Equal(t, "author1", matches[0].namespace) + assert.Equal(t, "author2", matches[1].namespace) + + // Build skills and verify they have different InstallNames + var skills []Skill + for _, m := range matches { + skills = append(skills, Skill{ + Name: m.name, + Namespace: m.namespace, + Convention: m.convention, + }) + } + assert.Equal(t, "author1/azure-diag", skills[0].InstallName()) + assert.Equal(t, "author2/azure-diag", skills[1].InstallName()) + assert.NotEqual(t, skills[0].InstallName(), skills[1].InstallName()) +} diff --git a/internal/skills/frontmatter/frontmatter.go b/internal/skills/frontmatter/frontmatter.go new file mode 100644 index 000000000..034068884 --- /dev/null +++ b/internal/skills/frontmatter/frontmatter.go @@ -0,0 +1,148 @@ +package frontmatter + +import ( + "bytes" + "fmt" + "strings" + + "gopkg.in/yaml.v3" +) + +const delimiter = "---" + +// Metadata represents the parsed YAML frontmatter of a SKILL.md file. +type Metadata struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + License string `yaml:"license,omitempty"` + Meta map[string]interface{} `yaml:"metadata,omitempty"` +} + +// ParseResult contains the parsed frontmatter and remaining body. +type ParseResult struct { + Metadata Metadata + Body string + RawYAML map[string]interface{} +} + +// Parse extracts YAML frontmatter from a SKILL.md file. +// Frontmatter is delimited by --- on its own lines. +func Parse(content string) (*ParseResult, error) { + trimmed := strings.TrimLeft(content, "\r\n") + if !strings.HasPrefix(trimmed, delimiter) { + return &ParseResult{Body: content}, nil + } + + rest := trimmed[len(delimiter):] + rest = strings.TrimLeft(rest, "\r\n") + endIdx := strings.Index(rest, "\n"+delimiter) + if endIdx == -1 { + return &ParseResult{Body: content}, nil + } + + yamlContent := rest[:endIdx] + body := rest[endIdx+len("\n"+delimiter):] + body = strings.TrimLeft(body, "\r\n") + + var rawYAML map[string]interface{} + if err := yaml.Unmarshal([]byte(yamlContent), &rawYAML); err != nil { + return nil, fmt.Errorf("invalid frontmatter YAML: %w", err) + } + + var meta Metadata + if err := yaml.Unmarshal([]byte(yamlContent), &meta); err != nil { + return nil, fmt.Errorf("invalid frontmatter YAML: %w", err) + } + + return &ParseResult{ + Metadata: meta, + Body: body, + RawYAML: rawYAML, + }, nil +} + +// InjectGitHubMetadata adds GitHub tracking metadata to the spec-defined +// "metadata" map in frontmatter. Keys are prefixed with "github-" to avoid +// collisions with other tools' metadata. +// pinnedRef is the user's explicit --pin value; empty string means unpinned. +// skillPath is the skill's source path in the repo (e.g. "skills/author/my-skill"). +func InjectGitHubMetadata(content string, owner, repo, ref, sha, treeSHA, pinnedRef, skillPath string) (string, error) { + result, err := Parse(content) + if err != nil { + return "", err + } + + if result.RawYAML == nil { + result.RawYAML = make(map[string]interface{}) + } + + meta, _ := result.RawYAML["metadata"].(map[string]interface{}) + if meta == nil { + meta = make(map[string]interface{}) + } + meta["github-owner"] = owner + meta["github-repo"] = repo + meta["github-ref"] = ref + meta["github-sha"] = sha + meta["github-tree-sha"] = treeSHA + meta["github-path"] = skillPath + if pinnedRef != "" { + meta["github-pinned"] = pinnedRef + } else { + delete(meta, "github-pinned") + } + result.RawYAML["metadata"] = meta + + return Serialize(result.RawYAML, result.Body) +} + +// InjectLocalMetadata adds local-source tracking metadata to frontmatter. +// sourcePath is the absolute path to the source skill directory. +func InjectLocalMetadata(content string, sourcePath string) (string, error) { + result, err := Parse(content) + if err != nil { + return "", err + } + + if result.RawYAML == nil { + result.RawYAML = make(map[string]interface{}) + } + + meta, _ := result.RawYAML["metadata"].(map[string]interface{}) + if meta == nil { + meta = make(map[string]interface{}) + } + delete(meta, "github-owner") + delete(meta, "github-repo") + delete(meta, "github-ref") + delete(meta, "github-sha") + delete(meta, "github-tree-sha") + delete(meta, "github-pinned") + delete(meta, "github-path") + meta["local-path"] = sourcePath + result.RawYAML["metadata"] = meta + + return Serialize(result.RawYAML, result.Body) +} + +// Serialize writes a frontmatter map and body back to a SKILL.md string. +func Serialize(frontmatter map[string]interface{}, body string) (string, error) { + var buf bytes.Buffer + + yamlBytes, err := yaml.Marshal(frontmatter) + if err != nil { + return "", fmt.Errorf("failed to serialize frontmatter: %w", err) + } + + buf.WriteString(delimiter + "\n") + buf.Write(yamlBytes) + buf.WriteString(delimiter + "\n") + if body != "" { + buf.WriteString(body) + if !strings.HasSuffix(body, "\n") { + buf.WriteString("\n") + } + } + + return buf.String(), nil +} diff --git a/internal/skills/frontmatter/frontmatter_test.go b/internal/skills/frontmatter/frontmatter_test.go new file mode 100644 index 000000000..02bd1ee0e --- /dev/null +++ b/internal/skills/frontmatter/frontmatter_test.go @@ -0,0 +1,178 @@ +package frontmatter + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParse(t *testing.T) { + tests := []struct { + name string + content string + wantName string + wantDesc string + wantBody string + wantErr bool + }{ + { + name: "valid frontmatter", + content: "---\nname: test-skill\ndescription: A test skill\n---\n# Body\n", + wantName: "test-skill", + wantDesc: "A test skill", + wantBody: "# Body\n", + }, + { + name: "no frontmatter", + content: "# Just a markdown file\n", + wantBody: "# Just a markdown file\n", + }, + { + name: "invalid YAML", + content: "---\n: invalid yaml [[\n---\n", + wantErr: true, + }, + { + name: "no closing delimiter", + content: "---\nname: test\n", + wantBody: "---\nname: test\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := Parse(tt.content) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantName, result.Metadata.Name) + assert.Equal(t, tt.wantDesc, result.Metadata.Description) + assert.Equal(t, tt.wantBody, result.Body) + }) + } +} + +func TestInjectGitHubMetadata(t *testing.T) { + tests := []struct { + name string + content string + owner string + repo string + ref string + sha string + treeSHA string + pinnedRef string + skillPath string + wantContains []string + wantNotContain []string + }{ + { + name: "injects metadata without pin", + content: "---\nname: my-skill\ndescription: desc\n---\n# Body\n", + owner: "owner", + repo: "repo", + ref: "v1.0.0", + sha: "abc123", + treeSHA: "tree456", + pinnedRef: "", + skillPath: "skills/my-skill", + wantContains: []string{ + "github-owner: owner", + "github-repo: repo", + "github-ref: v1.0.0", + "github-sha: abc123", + "github-tree-sha: tree456", + "github-path: skills/my-skill", + "# Body", + }, + wantNotContain: []string{ + "github-pinned", + }, + }, + { + name: "injects pinned ref", + content: "---\nname: my-skill\n---\n# Body\n", + owner: "owner", + repo: "repo", + ref: "v1.0.0", + sha: "abc", + treeSHA: "tree", + pinnedRef: "v1.0.0", + skillPath: "skills/my-skill", + wantContains: []string{ + "github-pinned: v1.0.0", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := InjectGitHubMetadata(tt.content, tt.owner, tt.repo, tt.ref, tt.sha, tt.treeSHA, tt.pinnedRef, tt.skillPath) + require.NoError(t, err) + for _, s := range tt.wantContains { + assert.Contains(t, got, s) + } + for _, s := range tt.wantNotContain { + assert.NotContains(t, got, s) + } + }) + } +} + +func TestInjectLocalMetadata(t *testing.T) { + content := "---\nname: my-skill\nmetadata:\n github-owner: old\n github-repo: old\n---\n# Body\n" + got, err := InjectLocalMetadata(content, "/home/user/skills/my-skill") + require.NoError(t, err) + + assert.Contains(t, got, "local-path: /home/user/skills/my-skill") + assert.NotContains(t, got, "github-owner") + assert.NotContains(t, got, "github-repo") +} + +func TestSerialize(t *testing.T) { + tests := []struct { + name string + frontmatter map[string]interface{} + body string + wantPrefix string + wantSuffix string + wantContains []string + }{ + { + name: "with body", + frontmatter: map[string]interface{}{"name": "test"}, + body: "# Body content", + wantPrefix: "---\n", + wantContains: []string{ + "name: test", + "# Body content", + }, + }, + { + name: "empty body", + frontmatter: map[string]interface{}{"name": "test"}, + body: "", + wantSuffix: "---\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Serialize(tt.frontmatter, tt.body) + require.NoError(t, err) + if tt.wantPrefix != "" { + assert.True(t, strings.HasPrefix(got, tt.wantPrefix)) + } + if tt.wantSuffix != "" { + assert.True(t, strings.HasSuffix(got, tt.wantSuffix)) + } + for _, s := range tt.wantContains { + assert.Contains(t, got, s) + } + }) + } +} diff --git a/internal/skills/gitclient/gitclient.go b/internal/skills/gitclient/gitclient.go new file mode 100644 index 000000000..99735db90 --- /dev/null +++ b/internal/skills/gitclient/gitclient.go @@ -0,0 +1,149 @@ +// Package gitclient provides a shared adapter from the cli/cli git.Client +// (via cmdutil.Factory) to the narrow interfaces used by skills commands. +package gitclient + +import ( + "context" + "os" + "strings" + + "github.com/cli/cli/v2/pkg/cmdutil" +) + +// RootResolver can resolve the git repository root directory. +type RootResolver interface { + ToplevelDir() (string, error) +} + +// RemoteResolver can resolve git remote URLs. +type RemoteResolver interface { + RemoteURL(name string) (string, error) +} + +// Client is the full git operations interface used by skills commands. +type Client interface { + RootResolver + RemoteResolver + GitDir(dir string) error + Remotes() ([]string, error) + CurrentBranch(dir string) (string, error) + IsIgnored(dir, path string) bool +} + +// FactoryClient adapts the cli/cli git.Client to the Client interface. +type FactoryClient struct { + F *cmdutil.Factory +} + +// ToplevelDir returns the root directory of the current git repository. +func (g *FactoryClient) ToplevelDir() (string, error) { + cmd, err := g.F.GitClient.Command(context.Background(), "rev-parse", "--show-toplevel") + if err != nil { + return "", err + } + out, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +// RemoteURL returns the URL configured for the named git remote. +func (g *FactoryClient) RemoteURL(name string) (string, error) { + cmd, err := g.F.GitClient.Command(context.Background(), "remote", "get-url", name) + if err != nil { + return "", err + } + out, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +// GitDir validates that the given directory is inside a git repository. +func (g *FactoryClient) GitDir(dir string) error { + cmd, err := g.F.GitClient.Command(context.Background(), "-C", dir, "rev-parse", "--git-dir") + if err != nil { + return err + } + _, err = cmd.Output() + return err +} + +// Remotes returns the list of configured git remote names. +func (g *FactoryClient) Remotes() ([]string, error) { + cmd, err := g.F.GitClient.Command(context.Background(), "remote") + if err != nil { + return nil, err + } + out, err := cmd.Output() + if err != nil { + return nil, err + } + return strings.Fields(string(out)), nil +} + +// CurrentBranch returns the current branch name, or "" if HEAD is detached. +func (g *FactoryClient) CurrentBranch(dir string) (string, error) { + cmd, err := g.F.GitClient.Command(context.Background(), "-C", dir, "rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + return "", err + } + out, err := cmd.Output() + if err != nil { + return "", err + } + branch := strings.TrimSpace(string(out)) + if branch == "HEAD" { + return "", nil // detached HEAD + } + return branch, nil +} + +// IsIgnored reports whether the given path is git-ignored in the given directory. +func (g *FactoryClient) IsIgnored(dir, path string) bool { + cmd, err := g.F.GitClient.Command(context.Background(), "-C", dir, "check-ignore", "-q", path) + if err != nil { + return false + } + _, err = cmd.Output() + return err == nil +} + +// ResolveGitRoot returns the git repository root using the provided resolver, +// falling back to the current working directory on error. +func ResolveGitRoot(resolver RootResolver) string { + if resolver == nil { + if cwd, err := os.Getwd(); err == nil { + return cwd + } + return "" + } + root, err := resolver.ToplevelDir() + if err != nil { + if cwd, cwdErr := os.Getwd(); cwdErr == nil { + return cwd + } + return "" + } + return root +} + +// ResolveHomeDir returns the user's home directory, or "" on error. +func ResolveHomeDir() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return home +} + +// TruncateSHA returns the first 8 characters of a SHA, or the full string +// if it is shorter. +func TruncateSHA(sha string) string { + if len(sha) > 8 { + return sha[:8] + } + return sha +} diff --git a/internal/skills/gitclient/gitclient_test.go b/internal/skills/gitclient/gitclient_test.go new file mode 100644 index 000000000..0b8a2cfff --- /dev/null +++ b/internal/skills/gitclient/gitclient_test.go @@ -0,0 +1,49 @@ +package gitclient + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +type mockResolver struct { + root string + err error +} + +func (m *mockResolver) ToplevelDir() (string, error) { + if m.err != nil { + return "", m.err + } + return m.root, nil +} + +func TestResolveGitRoot(t *testing.T) { + t.Run("returns root on success", func(t *testing.T) { + got := ResolveGitRoot(&mockResolver{root: "/my/repo"}) + assert.Equal(t, "/my/repo", got) + }) + + t.Run("falls back to cwd on error", func(t *testing.T) { + got := ResolveGitRoot(&mockResolver{err: fmt.Errorf("not a git repo")}) + assert.NotEmpty(t, got) // falls back to cwd + }) + + t.Run("nil resolver falls back to cwd", func(t *testing.T) { + got := ResolveGitRoot(nil) + assert.NotEmpty(t, got) // falls back to cwd + }) +} + +func TestResolveHomeDir(t *testing.T) { + got := ResolveHomeDir() + assert.NotEmpty(t, got) +} + +func TestTruncateSHA(t *testing.T) { + assert.Equal(t, "abcdef12", TruncateSHA("abcdef1234567890")) + assert.Equal(t, "short", TruncateSHA("short")) + assert.Equal(t, "12345678", TruncateSHA("12345678")) + assert.Equal(t, "", TruncateSHA("")) +} diff --git a/internal/skills/hosts/hosts.go b/internal/skills/hosts/hosts.go new file mode 100644 index 000000000..bee20b0f0 --- /dev/null +++ b/internal/skills/hosts/hosts.go @@ -0,0 +1,175 @@ +package hosts + +import ( + "fmt" + "path/filepath" + + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/ghrepo" +) + +// Host represents an AI agent that can use skills. +type Host struct { + // ID is the canonical identifier for this host. + ID string + // Name is the human-readable display name. + Name string + // ProjectDir is the relative path within a project for skills. + ProjectDir string + // UserDir is the relative path within the user's home directory for skills. + UserDir string +} + +// Scope determines where skills are installed. +type Scope string + +const ( + ScopeProject Scope = "project" + ScopeUser Scope = "user" +) + +// Registry contains all known agent hosts. +var Registry = []Host{ + { + ID: "github-copilot", + Name: "GitHub Copilot", + ProjectDir: ".github/skills", + UserDir: ".copilot/skills", + }, + { + ID: "claude-code", + Name: "Claude Code", + ProjectDir: ".claude/skills", + UserDir: ".claude/skills", + }, + { + ID: "cursor", + Name: "Cursor", + ProjectDir: ".cursor/skills", + UserDir: ".cursor/skills", + }, + { + ID: "codex", + Name: "Codex", + ProjectDir: ".agents/skills", + UserDir: ".codex/skills", + }, + { + ID: "gemini", + Name: "Gemini CLI", + ProjectDir: ".agent/skills", + UserDir: ".gemini/skills", + }, + { + ID: "antigravity", + Name: "Antigravity", + ProjectDir: ".agent/skills", + UserDir: ".gemini/antigravity/skills", + }, +} + +// FindByID returns the host with the given ID, or an error if not found. +func FindByID(id string) (*Host, error) { + for i := range Registry { + if Registry[i].ID == id { + return &Registry[i], nil + } + } + return nil, fmt.Errorf("unknown host %q, valid hosts: %s", id, ValidHostIDs()) +} + +// ValidHostIDs returns a comma-separated list of valid host IDs. +func ValidHostIDs() string { + ids := "" + for i, h := range Registry { + if i > 0 { + ids += ", " + } + ids += h.ID + } + return ids +} + +// HostIDs returns the IDs of all known hosts as a slice. +func HostIDs() []string { + ids := make([]string, len(Registry)) + for i, h := range Registry { + ids[i] = h.ID + } + return ids +} + +// HostNames returns the display names of all hosts for prompting. +func HostNames() []string { + names := make([]string, len(Registry)) + for i, h := range Registry { + names[i] = h.Name + } + return names +} + +// UniqueProjectDirs returns the deduplicated set of project-scope skill +// directories from the Registry, preserving insertion order. +func UniqueProjectDirs() []string { + seen := map[string]bool{} + var dirs []string + for _, h := range Registry { + if !seen[h.ProjectDir] { + seen[h.ProjectDir] = true + dirs = append(dirs, h.ProjectDir) + } + } + return dirs +} + +// InstallDir resolves the absolute installation directory for a host and scope. +// For project scope, it uses the provided git root directory so that skills are +// installed at the top level regardless of which subdirectory the user is in. +// Returns an error when gitRoot is empty (not in a git repository). +// For user scope, it uses the home directory. +func (h *Host) InstallDir(scope Scope, gitRoot, homeDir string) (string, error) { + switch scope { + case ScopeProject: + if gitRoot == "" { + return "", fmt.Errorf("could not determine project root directory") + } + return filepath.Join(gitRoot, h.ProjectDir), nil + case ScopeUser: + if homeDir == "" { + return "", fmt.Errorf("could not determine home directory") + } + return filepath.Join(homeDir, h.UserDir), nil + default: + return "", fmt.Errorf("invalid scope %q", scope) + } +} + +// ScopeLabels returns the display labels for the scope selection prompt. +// If repoName is non-empty, it is included in the project-scope label +// for additional context. +func ScopeLabels(repoName string) []string { + projectLabel := "Project — install in current repository (recommended)" + if repoName != "" { + projectLabel = fmt.Sprintf("Project — %s (recommended)", repoName) + } + return []string{ + projectLabel, + "Global — install in home directory (available everywhere)", + } +} + +// RepoNameFromRemote extracts "owner/repo" from a git remote URL. +func RepoNameFromRemote(remote string) string { + if remote == "" { + return "" + } + u, err := git.ParseURL(remote) + if err != nil { + return "" + } + repo, err := ghrepo.FromURL(u) + if err != nil { + return "" + } + return ghrepo.FullName(repo) +} diff --git a/internal/skills/hosts/hosts_test.go b/internal/skills/hosts/hosts_test.go new file mode 100644 index 000000000..78c2a3e9d --- /dev/null +++ b/internal/skills/hosts/hosts_test.go @@ -0,0 +1,113 @@ +package hosts + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFindByID(t *testing.T) { + host, err := FindByID("github-copilot") + require.NoError(t, err) + assert.Equal(t, "GitHub Copilot", host.Name) + assert.Equal(t, ".github/skills", host.ProjectDir) +} + +func TestFindByID_Invalid(t *testing.T) { + _, err := FindByID("nonexistent") + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown host") +} + +func TestValidHostIDs(t *testing.T) { + ids := ValidHostIDs() + assert.Contains(t, ids, "github-copilot") + assert.Contains(t, ids, "claude-code") + assert.Contains(t, ids, "cursor") +} + +func TestHostNames(t *testing.T) { + names := HostNames() + assert.Contains(t, names, "GitHub Copilot") + assert.Contains(t, names, "Claude Code") +} + +func TestInstallDir_Project(t *testing.T) { + host, _ := FindByID("github-copilot") + dir, err := host.InstallDir(ScopeProject, "/tmp/myrepo", "/home/user") + require.NoError(t, err) + assert.Equal(t, filepath.Join("/tmp/myrepo", ".github", "skills"), dir) +} + +func TestInstallDir_User(t *testing.T) { + host, _ := FindByID("github-copilot") + dir, err := host.InstallDir(ScopeUser, "/tmp/myrepo", "/home/user") + require.NoError(t, err) + assert.Equal(t, filepath.Join("/home/user", ".copilot", "skills"), dir) +} + +func TestInstallDir_NoGitRoot(t *testing.T) { + host, _ := FindByID("github-copilot") + _, err := host.InstallDir(ScopeProject, "", "/home/user") + assert.Error(t, err) +} + +func TestRepoNameFromRemote(t *testing.T) { + tests := []struct { + remote string + want string + }{ + {"https://github.com/owner/repo.git", "owner/repo"}, + {"https://github.com/owner/repo", "owner/repo"}, + {"git@github.com:owner/repo.git", "owner/repo"}, + {"git@github.com:owner/repo", "owner/repo"}, + {"ssh://git@github.com/owner/repo.git", "owner/repo"}, + {"ssh://git@github.com/owner/repo", "owner/repo"}, + {"", ""}, + } + for _, tt := range tests { + t.Run(tt.remote, func(t *testing.T) { + assert.Equal(t, tt.want, RepoNameFromRemote(tt.remote)) + }) + } +} + +func TestUniqueProjectDirs(t *testing.T) { + dirs := UniqueProjectDirs() + + // Should contain all known project dirs + assert.Contains(t, dirs, ".github/skills") + assert.Contains(t, dirs, ".claude/skills") + assert.Contains(t, dirs, ".cursor/skills") + assert.Contains(t, dirs, ".agents/skills") + assert.Contains(t, dirs, ".agent/skills") + + // Should deduplicate — gemini and antigravity share .agent/skills + seen := map[string]int{} + for _, d := range dirs { + seen[d]++ + } + for dir, count := range seen { + assert.Equalf(t, 1, count, "directory %q appears %d times, expected 1", dir, count) + } +} + +func TestScopeLabels(t *testing.T) { + t.Run("without repo name", func(t *testing.T) { + labels := ScopeLabels("") + require.Len(t, labels, 2) + assert.Contains(t, labels[0], "Project") + assert.Contains(t, labels[0], "recommended") + assert.Contains(t, labels[1], "Global") + }) + + t.Run("with repo name", func(t *testing.T) { + labels := ScopeLabels("owner/repo") + require.Len(t, labels, 2) + assert.Contains(t, labels[0], "owner/repo") + assert.Contains(t, labels[0], "recommended") + assert.Contains(t, labels[1], "Global") + }) +} diff --git a/internal/skills/installer/installer.go b/internal/skills/installer/installer.go new file mode 100644 index 000000000..fa2854a7c --- /dev/null +++ b/internal/skills/installer/installer.go @@ -0,0 +1,296 @@ +package installer + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/internal/skills/frontmatter" + "github.com/cli/cli/v2/internal/skills/hosts" + "github.com/cli/cli/v2/internal/skills/lockfile" +) + +// maxConcurrency limits parallel API requests to avoid rate limiting. +const maxConcurrency = 5 + +// Options configures an installation. +type Options struct { + Host string // GitHub API hostname + Owner string + Repo string + Ref string // resolved ref name + SHA string // resolved commit SHA + PinnedRef string // user-supplied --pin value (empty if unpinned) + Skills []discovery.Skill + AgentHost *hosts.Host + Scope hosts.Scope + Dir string // explicit target directory (overrides AgentHost+Scope) + GitRoot string // git repository root (for project scope) + HomeDir string // user home directory (for user scope) + Client discovery.RESTClient + OnProgress func(done, total int) // called after each skill is installed +} + +// Result tracks what was installed. +type Result struct { + Installed []string + Dir string + Warnings []string +} + +type skillResult struct { + name string + err error +} + +// Install fetches and writes skills to the target directory. +func Install(opts *Options) (*Result, error) { + targetDir := opts.Dir + if targetDir == "" { + var err error + targetDir, err = opts.AgentHost.InstallDir(opts.Scope, opts.GitRoot, opts.HomeDir) + if err != nil { + return nil, err + } + } + + if len(opts.Skills) == 1 { + skill := opts.Skills[0] + if opts.OnProgress != nil { + opts.OnProgress(0, 1) + } + if err := installSkill(opts, skill, targetDir); err != nil { + return nil, fmt.Errorf("failed to install skill %q: %w", skill.InstallName(), err) + } + var warnings []string + if err := lockfile.RecordInstall(skill.InstallName(), opts.Owner, opts.Repo, skill.Path+"/SKILL.md", skill.TreeSHA, opts.PinnedRef); err != nil { + warnings = append(warnings, fmt.Sprintf("could not record install for %s: %v", skill.InstallName(), err)) + } + if opts.OnProgress != nil { + opts.OnProgress(1, 1) + } + return &Result{Installed: []string{skill.InstallName()}, Dir: targetDir, Warnings: warnings}, nil + } + + total := len(opts.Skills) + if opts.OnProgress != nil { + opts.OnProgress(0, total) + } + + sem := make(chan struct{}, maxConcurrency) + results := make([]skillResult, total) + var wg sync.WaitGroup + var mu sync.Mutex + done := 0 + + for i, skill := range opts.Skills { + wg.Add(1) + go func(idx int, s discovery.Skill) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + err := installSkill(opts, s, targetDir) + results[idx] = skillResult{name: s.InstallName(), err: err} + + if opts.OnProgress != nil { + mu.Lock() + done++ + d := done + mu.Unlock() + opts.OnProgress(d, total) + } + }(i, skill) + } + wg.Wait() + + var installed []string + var warnings []string + var firstErr error + for i, r := range results { + if r.err != nil { + if firstErr == nil { + firstErr = fmt.Errorf("failed to install skill %q: %w", r.name, r.err) + } + continue + } + installed = append(installed, r.name) + skill := opts.Skills[i] + if err := lockfile.RecordInstall(skill.InstallName(), opts.Owner, opts.Repo, skill.Path+"/SKILL.md", skill.TreeSHA, opts.PinnedRef); err != nil { + warnings = append(warnings, fmt.Sprintf("could not record install for %s: %v", skill.InstallName(), err)) + } + } + + if firstErr != nil { + return &Result{Installed: installed, Dir: targetDir, Warnings: warnings}, firstErr + } + + return &Result{Installed: installed, Dir: targetDir, Warnings: warnings}, nil +} + +// LocalOptions configures a local directory installation. +type LocalOptions struct { + SourceDir string + Skills []discovery.Skill + AgentHost *hosts.Host + Scope hosts.Scope + Dir string + GitRoot string + HomeDir string +} + +// InstallLocal copies skills from a local directory to the target install location. +func InstallLocal(opts *LocalOptions) (*Result, error) { + targetDir := opts.Dir + if targetDir == "" { + var err error + targetDir, err = opts.AgentHost.InstallDir(opts.Scope, opts.GitRoot, opts.HomeDir) + if err != nil { + return nil, err + } + } + + var installed []string + for _, skill := range opts.Skills { + if err := installLocalSkill(opts.SourceDir, skill, targetDir); err != nil { + return nil, fmt.Errorf("failed to install skill %q: %w", skill.InstallName(), err) + } + installed = append(installed, skill.InstallName()) + } + + return &Result{Installed: installed, Dir: targetDir}, nil +} + +func installLocalSkill(sourceRoot string, skill discovery.Skill, baseDir string) error { + skillDir := filepath.Join(baseDir, filepath.FromSlash(skill.InstallName())) + if err := os.MkdirAll(skillDir, 0o755); err != nil { + return fmt.Errorf("could not create directory %s: %w", skillDir, err) + } + + srcDir := filepath.Join(sourceRoot, filepath.FromSlash(skill.Path)) + absSource, err := filepath.Abs(srcDir) + if err != nil { + return fmt.Errorf("could not resolve source path: %w", err) + } + + absSkillDir, err := filepath.Abs(skillDir) + if err != nil { + return fmt.Errorf("could not resolve target path: %w", err) + } + + return filepath.WalkDir(srcDir, func(p string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.Type()&os.ModeSymlink != 0 { + return nil + } + if d.IsDir() { + return nil + } + + relPath, err := filepath.Rel(srcDir, p) + if err != nil { + return err + } + + cleaned := filepath.Clean(relPath) + if cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) { + return nil + } + + destPath := filepath.Join(skillDir, cleaned) + + absDest, err := filepath.Abs(destPath) + if err != nil { + return fmt.Errorf("could not resolve destination path: %w", err) + } + if !strings.HasPrefix(absDest, absSkillDir+string(filepath.Separator)) && absDest != absSkillDir { + return nil + } + + if dir := filepath.Dir(destPath); dir != skillDir { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("could not create directory: %w", err) + } + } + + content, err := os.ReadFile(p) + if err != nil { + return fmt.Errorf("could not read %s: %w", p, err) + } + + if filepath.Base(relPath) == "SKILL.md" { + injected, injectErr := frontmatter.InjectLocalMetadata(string(content), absSource) + if injectErr != nil { + return fmt.Errorf("could not inject metadata: %w", injectErr) + } + content = []byte(injected) + } + + return os.WriteFile(destPath, content, 0o644) + }) +} + +func installSkill(opts *Options, skill discovery.Skill, baseDir string) error { + skillDir := filepath.Join(baseDir, filepath.FromSlash(skill.InstallName())) + if err := os.MkdirAll(skillDir, 0o755); err != nil { + return fmt.Errorf("could not create directory %s: %w", skillDir, err) + } + + files, err := discovery.DiscoverSkillFiles(opts.Client, opts.Host, opts.Owner, opts.Repo, skill.TreeSHA, skill.Path) + if err != nil { + return fmt.Errorf("could not list skill files: %w", err) + } + + absSkillDir, err := filepath.Abs(skillDir) + if err != nil { + return fmt.Errorf("could not resolve skill directory path: %w", err) + } + + for _, file := range files { + content, err := discovery.FetchBlob(opts.Client, opts.Host, opts.Owner, opts.Repo, file.SHA) + if err != nil { + return fmt.Errorf("could not fetch %s: %w", file.Path, err) + } + + relPath := strings.TrimPrefix(file.Path, skill.Path+"/") + + cleaned := filepath.Clean(relPath) + if cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) { + continue + } + + destPath := filepath.Join(skillDir, cleaned) + + absDest, err := filepath.Abs(destPath) + if err != nil { + return fmt.Errorf("could not resolve destination path: %w", err) + } + if !strings.HasPrefix(absDest, absSkillDir+string(filepath.Separator)) && absDest != absSkillDir { + continue + } + + if dir := filepath.Dir(destPath); dir != skillDir { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("could not create directory: %w", err) + } + } + + if filepath.Base(relPath) == "SKILL.md" { + content, err = frontmatter.InjectGitHubMetadata(content, opts.Owner, opts.Repo, opts.Ref, file.SHA, skill.TreeSHA, opts.PinnedRef, skill.Path) + if err != nil { + return fmt.Errorf("could not inject metadata: %w", err) + } + } + + if err := os.WriteFile(destPath, []byte(content), 0o644); err != nil { + return fmt.Errorf("could not write %s: %w", destPath, err) + } + } + + return nil +} diff --git a/internal/skills/lockfile/lockfile.go b/internal/skills/lockfile/lockfile.go new file mode 100644 index 000000000..4ceed7872 --- /dev/null +++ b/internal/skills/lockfile/lockfile.go @@ -0,0 +1,165 @@ +package lockfile + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +const ( + // lockVersion must match Vercel's CURRENT_LOCK_VERSION for interop. + lockVersion = 3 + agentsDir = ".agents" + lockFile = ".skill-lock.json" +) + +// Entry represents a single installed skill in the lock file. +type Entry struct { + Source string `json:"source"` + SourceType string `json:"sourceType"` + SourceURL string `json:"sourceUrl"` + SkillPath string `json:"skillPath,omitempty"` + SkillFolderHash string `json:"skillFolderHash"` + InstalledAt string `json:"installedAt"` + UpdatedAt string `json:"updatedAt"` + PinnedRef string `json:"pinnedRef,omitempty"` +} + +// File is the top-level structure of .skill-lock.json. +type File struct { + Version int `json:"version"` + Skills map[string]Entry `json:"skills"` + Dismissed map[string]bool `json:"dismissed,omitempty"` +} + +// Path returns the absolute path to the lock file. +func Path() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, agentsDir, lockFile), nil +} + +// Read loads the lock file, returning an empty file if it doesn't exist +// or if it's an incompatible version. +func Read() (*File, error) { + lockPath, err := Path() + if err != nil { + return newFile(), nil //nolint:nilerr // graceful: no home dir means fresh state + } + + data, err := os.ReadFile(lockPath) + if err != nil { + if os.IsNotExist(err) { + return newFile(), nil + } + return nil, fmt.Errorf("could not read lock file: %w", err) + } + + var f File + if err := json.Unmarshal(data, &f); err != nil { + return newFile(), nil //nolint:nilerr // graceful: corrupt file means fresh state + } + + if f.Version != lockVersion || f.Skills == nil { + return newFile(), nil + } + + return &f, nil +} + +// Write persists the lock file to disk. +func Write(f *File) error { + lockPath, err := Path() + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { + return err + } + + data, err := json.MarshalIndent(f, "", " ") + if err != nil { + return err + } + + return os.WriteFile(lockPath, data, 0o644) +} + +// RecordInstall adds or updates a skill entry in the lock file. +// It uses a file-based lock to prevent concurrent read-modify-write races +// when multiple install processes run simultaneously. +func RecordInstall(skillName, owner, repo, skillPath, treeSHA, pinnedRef string) error { + unlock := acquireLock() + defer unlock() + + f, err := Read() + if err != nil { + return err + } + + now := time.Now().UTC().Format(time.RFC3339) + + existing, exists := f.Skills[skillName] + installedAt := now + if exists { + installedAt = existing.InstalledAt + } + + f.Skills[skillName] = Entry{ + Source: owner + "/" + repo, + SourceType: "github", + SourceURL: "https://github.com/" + owner + "/" + repo + ".git", + SkillPath: skillPath, + SkillFolderHash: treeSHA, + InstalledAt: installedAt, + UpdatedAt: now, + PinnedRef: pinnedRef, + } + + return Write(f) +} + +func newFile() *File { + return &File{ + Version: lockVersion, + Skills: make(map[string]Entry), + } +} + +// acquireLock creates an exclusive lock file to serialize concurrent access. +// Returns an unlock function. If locking fails after retries, it proceeds +// unlocked rather than blocking the user indefinitely. +func acquireLock() (unlock func()) { + lockPath, pathErr := Path() + if pathErr != nil { + return func() {} + } + lkPath := lockPath + ".lk" + + // Ensure the parent directory exists (fresh machine may lack ~/.agents). + if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { + return func() {} + } + + for i := 0; i < 30; i++ { + f, createErr := os.OpenFile(lkPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + if createErr == nil { + f.Close() + return func() { os.Remove(lkPath) } + } + // Break stale locks older than 30s (e.g. from a crashed process). + if info, statErr := os.Stat(lkPath); statErr == nil && time.Since(info.ModTime()) > 30*time.Second { + os.Remove(lkPath) + continue + } + time.Sleep(100 * time.Millisecond) + } + + // Best-effort: proceed without lock. + return func() {} +} From 5d049cb8970ab8d5acb03efd0b48e5a744381f33 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Mon, 30 Mar 2026 17:28:54 +0100 Subject: [PATCH 02/27] register initial skills commands --- pkg/cmd/root/root.go | 2 ++ pkg/cmd/skills/skills.go | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 pkg/cmd/skills/skills.go diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index ed33f568e..262af1b78 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -38,6 +38,7 @@ import ( runCmd "github.com/cli/cli/v2/pkg/cmd/run" searchCmd "github.com/cli/cli/v2/pkg/cmd/search" secretCmd "github.com/cli/cli/v2/pkg/cmd/secret" + skillsCmd "github.com/cli/cli/v2/pkg/cmd/skills" sshKeyCmd "github.com/cli/cli/v2/pkg/cmd/ssh-key" statusCmd "github.com/cli/cli/v2/pkg/cmd/status" variableCmd "github.com/cli/cli/v2/pkg/cmd/variable" @@ -164,6 +165,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command, cmd.AddCommand(repoCmd.NewCmdRepo(&repoResolvingCmdFactory)) cmd.AddCommand(rulesetCmd.NewCmdRuleset(&repoResolvingCmdFactory)) cmd.AddCommand(runCmd.NewCmdRun(&repoResolvingCmdFactory)) + cmd.AddCommand(skillsCmd.NewCmdSkills(f)) cmd.AddCommand(workflowCmd.NewCmdWorkflow(&repoResolvingCmdFactory)) cmd.AddCommand(labelCmd.NewCmdLabel(&repoResolvingCmdFactory)) cmd.AddCommand(cacheCmd.NewCmdCache(&repoResolvingCmdFactory)) diff --git a/pkg/cmd/skills/skills.go b/pkg/cmd/skills/skills.go new file mode 100644 index 000000000..e3f1c286f --- /dev/null +++ b/pkg/cmd/skills/skills.go @@ -0,0 +1,18 @@ +package skills + +import ( + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/spf13/cobra" +) + +// NewCmdSkills returns the top-level "skills" command. +func NewCmdSkills(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "skills ", + Short: "Install and manage agent skills", + Long: "Install and manage agent skills from GitHub repositories.", + GroupID: "core", + } + + return cmd +} From 758785b8f4af530849267b112274db455bcaab60 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Mon, 30 Mar 2026 17:32:23 +0100 Subject: [PATCH 03/27] improve test coverage/cleanup --- acceptance/acceptance_test.go | 10 +- acceptance/testdata/skills/.gitkeep | 0 .../skills/skills-install-alias.txtar | 3 + .../testdata/skills/skills-install-all.txtar | 5 + .../skills/skills-install-conflict.txtar | 8 + .../skills/skills-install-force.txtar | 11 + .../testdata/skills/skills-install-pin.txtar | 7 + .../skills/skills-install-scope.txtar | 9 + .../testdata/skills/skills-install.txtar | 10 + internal/skills/discovery/discovery.go | 8 +- internal/skills/installer/installer.go | 6 + internal/skills/lockfile/lockfile.go | 5 + pkg/cmd/skills/install/install.go | 993 ++++++++++++++++++ pkg/cmd/skills/install/install_test.go | 917 ++++++++++++++++ .../skills/install/install_windows_test.go | 63 ++ pkg/cmd/skills/skills.go | 3 + 16 files changed, 2055 insertions(+), 3 deletions(-) create mode 100644 acceptance/testdata/skills/.gitkeep create mode 100644 acceptance/testdata/skills/skills-install-alias.txtar create mode 100644 acceptance/testdata/skills/skills-install-all.txtar create mode 100644 acceptance/testdata/skills/skills-install-conflict.txtar create mode 100644 acceptance/testdata/skills/skills-install-force.txtar create mode 100644 acceptance/testdata/skills/skills-install-pin.txtar create mode 100644 acceptance/testdata/skills/skills-install-scope.txtar create mode 100644 acceptance/testdata/skills/skills-install.txtar create mode 100644 pkg/cmd/skills/install/install.go create mode 100644 pkg/cmd/skills/install/install_test.go create mode 100644 pkg/cmd/skills/install/install_windows_test.go diff --git a/acceptance/acceptance_test.go b/acceptance/acceptance_test.go index 98642afaf..7c3c6f6ce 100644 --- a/acceptance/acceptance_test.go +++ b/acceptance/acceptance_test.go @@ -14,9 +14,9 @@ import ( "math/rand" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/ghcmd" "github.com/cli/go-internal/testscript" - "github.com/MakeNowJust/heredoc" ) func ghMain() int { @@ -434,3 +434,11 @@ func (e *testScriptEnv) fromEnv() error { return nil } + +func TestSkills(t *testing.T) { + var tsEnv testScriptEnv + if err := tsEnv.fromEnv(); err != nil { + t.Fatal(err) + } + testscript.Run(t, testScriptParamsFor(tsEnv, "skills")) +} diff --git a/acceptance/testdata/skills/.gitkeep b/acceptance/testdata/skills/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/acceptance/testdata/skills/skills-install-alias.txtar b/acceptance/testdata/skills/skills-install-alias.txtar new file mode 100644 index 000000000..089474b3a --- /dev/null +++ b/acceptance/testdata/skills/skills-install-alias.txtar @@ -0,0 +1,3 @@ +# Install with "add" alias +exec gh skills add github/awesome-copilot git-commit --scope user --force --agent github-copilot +stdout 'Installed git-commit' diff --git a/acceptance/testdata/skills/skills-install-all.txtar b/acceptance/testdata/skills/skills-install-all.txtar new file mode 100644 index 000000000..6efd7747e --- /dev/null +++ b/acceptance/testdata/skills/skills-install-all.txtar @@ -0,0 +1,5 @@ +# Install all skills from a repo with mixed conventions (skills/ + plugins/) +# This previously failed with "conflicting names" — now uses namespaced dirs +exec gh skills install github/awesome-copilot --all --scope user --force --agent github-copilot +stdout 'Installed' +! stderr 'conflicting names' diff --git a/acceptance/testdata/skills/skills-install-conflict.txtar b/acceptance/testdata/skills/skills-install-conflict.txtar new file mode 100644 index 000000000..9e79e5a5e --- /dev/null +++ b/acceptance/testdata/skills/skills-install-conflict.txtar @@ -0,0 +1,8 @@ +# Install --all should handle skills with same name across conventions +# (skills/ and plugins/ directories) without collision errors +exec gh skills install github/awesome-copilot --all --force --dir $WORK/scope-test --agent github-copilot +stdout 'Installed' +! stderr 'conflicting names' + +# Verify skills were installed successfully +exists $WORK/scope-test/git-commit/SKILL.md diff --git a/acceptance/testdata/skills/skills-install-force.txtar b/acceptance/testdata/skills/skills-install-force.txtar new file mode 100644 index 000000000..5623fce84 --- /dev/null +++ b/acceptance/testdata/skills/skills-install-force.txtar @@ -0,0 +1,11 @@ +# Install with --force should overwrite an existing skill without error +exec gh skills install github/awesome-copilot git-commit --force --dir $WORK/force-test +stdout 'Installed git-commit' + +# Install again with --force — should succeed (overwrite) +exec gh skills install github/awesome-copilot git-commit --force --dir $WORK/force-test +stdout 'Installed git-commit' + +# Without --force, non-interactive should fail when skill exists +! exec gh skills install github/awesome-copilot git-commit --dir $WORK/force-test +stderr 'already installed' diff --git a/acceptance/testdata/skills/skills-install-pin.txtar b/acceptance/testdata/skills/skills-install-pin.txtar new file mode 100644 index 000000000..43d780e3e --- /dev/null +++ b/acceptance/testdata/skills/skills-install-pin.txtar @@ -0,0 +1,7 @@ +# Install with --pin to a specific ref +exec gh skills install github/awesome-copilot git-commit --scope user --force --pin main +stdout 'Installed git-commit' + +# Install without --pin should resolve latest version +exec gh skills install github/awesome-copilot git-commit --scope user --force +stdout 'Installed git-commit' diff --git a/acceptance/testdata/skills/skills-install-scope.txtar b/acceptance/testdata/skills/skills-install-scope.txtar new file mode 100644 index 000000000..9b8048ab5 --- /dev/null +++ b/acceptance/testdata/skills/skills-install-scope.txtar @@ -0,0 +1,9 @@ +# Install with --scope project (default) inside a git repo +exec git init $WORK/myrepo +exec gh skills install github/awesome-copilot git-commit --scope project --force --agent github-copilot --dir $WORK/myrepo/.github/skills +stdout 'Installed git-commit' +exists $WORK/myrepo/.github/skills/git-commit/SKILL.md + +# Install with --scope user +exec gh skills install github/awesome-copilot git-commit --scope user --force --agent github-copilot +stdout 'Installed git-commit' diff --git a/acceptance/testdata/skills/skills-install.txtar b/acceptance/testdata/skills/skills-install.txtar new file mode 100644 index 000000000..c04ced9d2 --- /dev/null +++ b/acceptance/testdata/skills/skills-install.txtar @@ -0,0 +1,10 @@ +# Install a single skill from a public repo +exec gh skills install github/awesome-copilot git-commit --scope user --force --agent github-copilot +stdout 'Installed git-commit' + +# Install with --dir to a custom directory +exec gh skills install github/awesome-copilot git-commit --force --dir $WORK/custom-skills +stdout 'Installed git-commit' + +# Verify the skill was written to the custom directory +exists $WORK/custom-skills/git-commit/SKILL.md diff --git a/internal/skills/discovery/discovery.go b/internal/skills/discovery/discovery.go index e0e881088..fc234716a 100644 --- a/internal/skills/discovery/discovery.go +++ b/internal/skills/discovery/discovery.go @@ -573,8 +573,8 @@ func FetchBlob(client RESTClient, host, owner, repo, sha string) (string, error) return "", fmt.Errorf("unexpected blob encoding: %s", blob.Encoding) } - // GitHub API returns base64 with embedded newlines; use the lenient - // RawStdEncoding decoder via a reader to handle them transparently. + // 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(blob.Content))) if err != nil { return "", fmt.Errorf("could not decode blob content: %w", err) @@ -615,6 +615,10 @@ func DiscoverLocalSkills(dir string) ([]Skill, 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 } diff --git a/internal/skills/installer/installer.go b/internal/skills/installer/installer.go index fa2854a7c..4c2a39256 100644 --- a/internal/skills/installer/installer.go +++ b/internal/skills/installer/installer.go @@ -50,6 +50,9 @@ type skillResult struct { func Install(opts *Options) (*Result, error) { targetDir := opts.Dir if targetDir == "" { + if opts.AgentHost == nil { + return nil, fmt.Errorf("either Dir or AgentHost must be specified") + } var err error targetDir, err = opts.AgentHost.InstallDir(opts.Scope, opts.GitRoot, opts.HomeDir) if err != nil { @@ -146,6 +149,9 @@ type LocalOptions struct { func InstallLocal(opts *LocalOptions) (*Result, error) { targetDir := opts.Dir if targetDir == "" { + if opts.AgentHost == nil { + return nil, fmt.Errorf("either Dir or AgentHost must be specified") + } var err error targetDir, err = opts.AgentHost.InstallDir(opts.Scope, opts.GitRoot, opts.HomeDir) if err != nil { diff --git a/internal/skills/lockfile/lockfile.go b/internal/skills/lockfile/lockfile.go index 4ceed7872..ad5fd4d4b 100644 --- a/internal/skills/lockfile/lockfile.go +++ b/internal/skills/lockfile/lockfile.go @@ -152,6 +152,11 @@ func acquireLock() (unlock func()) { f.Close() return func() { os.Remove(lkPath) } } + // Only retry when the lock file already exists (concurrent process). + // For other errors (permission denied, invalid path, etc.) give up immediately. + if !os.IsExist(createErr) { + return func() {} + } // Break stale locks older than 30s (e.g. from a crashed process). if info, statErr := os.Stat(lkPath); statErr == nil && time.Since(info.ModTime()) > 30*time.Second { os.Remove(lkPath) diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go new file mode 100644 index 000000000..38613800d --- /dev/null +++ b/pkg/cmd/skills/install/install.go @@ -0,0 +1,993 @@ +package install + +import ( + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/skills" + "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/internal/skills/frontmatter" + "github.com/cli/cli/v2/internal/skills/gitclient" + "github.com/cli/cli/v2/internal/skills/hosts" + "github.com/cli/cli/v2/internal/skills/installer" + "github.com/cli/cli/v2/internal/text" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +const ( + // allSkillsKey is the persistent option label for selecting all skills. + allSkillsKey = "(all skills)" + + // maxSearchResults caps how many skills are shown per search page in + // interactive selection, keeping the prompt readable. + maxSearchResults = 30 +) + +// installOptions holds all dependencies and user-provided flags for the install command. +type installOptions struct { + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + Prompter prompter.Prompter + GitClient installGitClient + + // Arguments + SkillSource string // owner/repo or local path + SkillName string // skill name, possibly with @version + + // Flags + Agent string // --agent flag + Scope string // --scope flag + ScopeChanged bool // true when --scope was explicitly set + Pin string // --pin flag + Dir string // --dir flag (overrides host+scope) + All bool // --all flag + Force bool // --force flag + + // Resolved at runtime + repo ghrepo.Interface // set when SkillSource is a GitHub repository + localPath string // set when SkillSource is a local directory + version string +} + +// installGitClient is the git interface needed by the install command. +type installGitClient interface { + gitclient.RootResolver + gitclient.RemoteResolver +} + +// NewCmdInstall creates the "skills install" command. +func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra.Command { + opts := &installOptions{ + IO: f.IOStreams, + Prompter: f.Prompter, + GitClient: &gitclient.FactoryClient{F: f}, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "install []", + Short: "Install agent skills from a GitHub repository", + Long: heredoc.Docf(` + Install agent skills from a GitHub repository or local directory into + your local environment. Skills are placed in a host-specific directory + at either project scope (inside the current git repository) or user + scope (in your home directory, available everywhere): + + Host Project User + GitHub Copilot .github/skills ~/.copilot/skills + Claude Code .claude/skills ~/.claude/skills + Cursor .cursor/skills ~/.cursor/skills + Codex .agents/skills ~/.codex/skills + Gemini CLI .agent/skills ~/.gemini/skills + Antigravity .agent/skills ~/.gemini/antigravity/skills + + Use %[1]s--agent%[1]s and %[1]s--scope%[1]s to control placement, or %[1]s--dir%[1]s for a + custom directory. The default scope is %[1]sproject%[1]s, and the default + agent is %[1]sgithub-copilot%[1]s (when running non-interactively). + + The first argument can be a GitHub repository in %[1]sOWNER/REPO%[1]s format + or a local directory path (e.g. %[1]s.%[1]s, %[1]s./my-skills%[1]s, %[1]s~/skills%[1]s). + For local directories, skills are auto-discovered using the same + conventions as remote repositories, and files are copied (not symlinked) + with local-path tracking metadata injected into frontmatter. + + Skills are discovered automatically using the %[1]sskills/*/SKILL.md%[1]s convention + defined by the Agent Skills specification. For more information on the specification, + see: https://agentskills.io/specification + + The skill argument can be a name, a namespaced name (%[1]sauthor/skill%[1]s), + or an exact path within the repository (%[1]sskills/author/skill%[1]s or + %[1]sskills/author/skill/SKILL.md%[1]s). + + Performance tip: when installing from a large repository with many + skills, providing an exact path instead of a skill name avoids a + full tree traversal of the repository, making the install significantly faster. + + When a skill name is provided without a version, the CLI resolves the + version in this order: + + 1. Latest tagged release in the repository + 2. Default branch HEAD + + To pin to a specific version, either append %[1]s@VERSION%[1]s to the skill + name or use the %[1]s--pin%[1]s flag. The version is resolved as a git tag or commit SHA. + + Installed skills have GitHub tracking metadata injected into their + frontmatter (%[1]sgithub-owner%[1]s, %[1]sgithub-repo%[1]s, %[1]sgithub-ref%[1]s, + %[1]sgithub-sha%[1]s, %[1]sgithub-tree-sha%[1]s, %[1]sgithub-path%[1]s). This + metadata identifies the source repository and enables %[1]sgh skills update%[1]s + to detect changes — the tree SHA serves as an ETag for staleness checks. + + When run interactively, the command prompts for any missing arguments. + When run non-interactively, %[1]srepository%[1]s is required, and either a + skill name or %[1]s--all%[1]s must be specified. + `, "`"), + Example: heredoc.Doc(` + # Interactive: choose repo, skill, and agent + $ gh skills install + + # Choose a skill from the repo interactively + $ gh skills install github/awesome-copilot + + # Install a specific skill + $ gh skills install github/awesome-copilot git-commit + + # Install a specific version + $ gh skills install github/awesome-copilot git-commit@v1.2.0 + + # Install all skills from a repo + $ gh skills install github/awesome-copilot --all + + # Install from a large namespaced repo by path (efficient, skips full discovery) + $ gh skills install github/awesome-copilot skills/monalisa/code-review + + # Install from a local directory (auto-discovers skills) + $ gh skills install ./my-skills-repo + + # Install from current directory + $ gh skills install . + + # Install a single local skill directory + $ gh skills install ./skills/git-commit + + # Install for Claude Code at user scope + $ gh skills install github/awesome-copilot git-commit --agent claude-code --scope user + + # Pin to a specific git ref + $ gh skills install github/awesome-copilot git-commit --pin v2.0.0 + `), + Aliases: []string{"add"}, + Args: cobra.MaximumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 && !opts.IO.CanPrompt() { + return cmdutil.FlagErrorf("must specify a repository to install from") + } + if len(args) >= 1 { + opts.SkillSource = args[0] + } + if len(args) >= 2 { + opts.SkillName = args[1] + } + opts.ScopeChanged = cmd.Flags().Changed("scope") + + // Resolve the source type early so installRun can branch directly. + if isLocalPath(opts.SkillSource) { + opts.localPath = opts.SkillSource + } + + if opts.Agent != "" { + if _, err := hosts.FindByID(opts.Agent); err != nil { + return cmdutil.FlagErrorf("invalid value for --agent: %s", err) + } + } + + if opts.Pin != "" && opts.SkillName != "" && strings.Contains(opts.SkillName, "@") { + return cmdutil.FlagErrorf("cannot use --pin with an inline @version in the skill name") + } + + if runF != nil { + return runF(opts) + } + return installRun(opts) + }, + } + + cmd.Flags().StringVar(&opts.Agent, "agent", "", fmt.Sprintf("target agent (%s)", hosts.ValidHostIDs())) + _ = cmd.RegisterFlagCompletionFunc("agent", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return hosts.HostIDs(), cobra.ShellCompDirectiveNoFileComp + }) + cmdutil.StringEnumFlag(cmd, &opts.Scope, "scope", "", "project", []string{"project", "user"}, "Installation scope") + cmd.Flags().StringVar(&opts.Pin, "pin", "", "pin to a specific git tag or commit SHA") + cmd.Flags().StringVar(&opts.Dir, "dir", "", "install to a custom directory (overrides --agent and --scope)") + cmd.Flags().BoolVar(&opts.All, "all", false, "install all skills from the repository") + cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "overwrite existing skills without prompting") + + return cmd +} + +func installRun(opts *installOptions) error { + cs := opts.IO.ColorScheme() + canPrompt := opts.IO.CanPrompt() + + if opts.localPath != "" { + return runLocalInstall(opts) + } + + repo, source, err := resolveRepoArg(opts.SkillSource, canPrompt, opts.Prompter) + if err != nil { + return err + } + opts.repo = repo + opts.SkillSource = source + + parseSkillFromOpts(opts) + + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + hostname := opts.repo.RepoHost() + + resolved, err := resolveVersion(opts, apiClient, hostname) + if err != nil { + return err + } + + var selectedSkills []discovery.Skill + + if isSkillPath(opts.SkillName) { + opts.IO.StartProgressIndicatorWithLabel("Looking up skill") + skill, err := discovery.DiscoverSkillByPath(apiClient, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), resolved.SHA, opts.SkillName) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + selectedSkills = []discovery.Skill{*skill} + } else { + skills, err := discoverSkills(opts, apiClient, hostname, resolved) + if err != nil { + return err + } + + selectedSkills, err = selectSkillsWithSelector(opts, skills, canPrompt, skillSelector{ + matchByName: matchSkillByName, + sourceHint: ghrepo.FullName(opts.repo), + fetchDescriptions: func() { + opts.IO.StartProgressIndicatorWithLabel("Fetching skill info") + discovery.FetchDescriptionsConcurrent(apiClient, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), skills, nil) + opts.IO.StopProgressIndicator() + }, + }) + if err != nil { + return err + } + } + + selectedHosts, err := resolveHosts(opts, canPrompt) + if err != nil { + return err + } + + scope, err := resolveScope(opts, canPrompt) + if err != nil { + return err + } + + gitRoot := gitclient.ResolveGitRoot(opts.GitClient) + homeDir := gitclient.ResolveHomeDir() + source = ghrepo.FullName(opts.repo) + + type hostPlan struct { + host *hosts.Host + skills []discovery.Skill + } + var plans []hostPlan + for _, host := range selectedHosts { + installSkills, err := checkOverwrite(opts, selectedSkills, host, scope, gitRoot, homeDir, canPrompt) + if err != nil { + return err + } + if len(installSkills) == 0 { + fmt.Fprintf(opts.IO.ErrOut, "No skills to install for %s.\n", host.Name) + continue + } + plans = append(plans, hostPlan{host: host, skills: installSkills}) + } + + for _, plan := range plans { + if len(plans) > 1 { + fmt.Fprintf(opts.IO.ErrOut, "\nInstalling to %s...\n", plan.host.Name) + } + + result, err := installer.Install(&installer.Options{ + Host: hostname, + Owner: opts.repo.RepoOwner(), + Repo: opts.repo.RepoName(), + Ref: resolved.Ref, + SHA: resolved.SHA, + PinnedRef: opts.Pin, + Skills: plan.skills, + AgentHost: plan.host, + Scope: scope, + Dir: opts.Dir, + GitRoot: gitRoot, + HomeDir: homeDir, + Client: apiClient, + OnProgress: installProgress(opts.IO, len(plan.skills)), + }) + + if result != nil { + for _, w := range result.Warnings { + fmt.Fprintf(opts.IO.ErrOut, "%s %s\n", cs.WarningIcon(), w) + } + + for _, name := range result.Installed { + fmt.Fprintf(opts.IO.Out, "%s Installed %s (from %s@%s) in %s\n", + cs.SuccessIcon(), name, source, resolved.Ref, friendlyDir(result.Dir)) + } + + printFileTree(opts.IO.Out, cs, result.Dir, result.Installed) + printReviewHint(opts.IO.ErrOut, cs, source, result.Installed) + } + + if err != nil { + return err + } + } + + return nil +} + +// isLocalPath returns true if the argument looks like a local filesystem path +// rather than a GitHub owner/repo reference. +func isLocalPath(arg string) bool { + if arg == "" { + return false + } + sep := string(filepath.Separator) + if arg == "." || arg == ".." || + strings.HasPrefix(arg, "./") || strings.HasPrefix(arg, "../") || + strings.HasPrefix(arg, "."+sep) || strings.HasPrefix(arg, ".."+sep) { + return true + } + // filepath.IsAbs on Windows requires a drive letter, so "/tmp/foo" + // would not be recognized. Check explicitly for a leading "/" so that + // Unix-style absolute paths are never mistaken for owner/repo refs. + if filepath.IsAbs(arg) || arg[0] == '/' || strings.HasPrefix(arg, "~") { + return true + } + info, err := os.Stat(arg) + if err == nil && info.IsDir() { + return true + } + return false +} + +// runLocalInstall handles installation from a local directory path. +func runLocalInstall(opts *installOptions) error { + cs := opts.IO.ColorScheme() + canPrompt := opts.IO.CanPrompt() + sourcePath := opts.localPath + if sourcePath == "~" { + if home, err := os.UserHomeDir(); err == nil { + sourcePath = home + } + } else if after, ok := strings.CutPrefix(sourcePath, "~/"); ok { + if home, err := os.UserHomeDir(); err == nil { + sourcePath = filepath.Join(home, after) + } + } + + absSource, err := filepath.Abs(sourcePath) + if err != nil { + return fmt.Errorf("could not resolve path: %w", err) + } + + opts.IO.StartProgressIndicatorWithLabel("Discovering skills") + skills, err := discovery.DiscoverLocalSkills(absSource) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + if canPrompt { + fmt.Fprintf(opts.IO.ErrOut, "Found %d skill(s)\n", len(skills)) + } + + selectedSkills, err := selectSkillsWithSelector(opts, skills, canPrompt, skillSelector{ + matchByName: matchLocalSkillByName, + sourceHint: absSource, + }) + if err != nil { + return err + } + + selectedHosts, err := resolveHosts(opts, canPrompt) + if err != nil { + return err + } + + scope, err := resolveScope(opts, canPrompt) + if err != nil { + return err + } + + gitRoot := gitclient.ResolveGitRoot(opts.GitClient) + homeDir := gitclient.ResolveHomeDir() + + type hostPlan struct { + host *hosts.Host + skills []discovery.Skill + } + var plans []hostPlan + for _, host := range selectedHosts { + installSkills, err := checkOverwrite(opts, selectedSkills, host, scope, gitRoot, homeDir, canPrompt) + if err != nil { + return err + } + if len(installSkills) == 0 { + fmt.Fprintf(opts.IO.ErrOut, "No skills to install for %s.\n", host.Name) + continue + } + plans = append(plans, hostPlan{host: host, skills: installSkills}) + } + + for _, plan := range plans { + if len(plans) > 1 { + fmt.Fprintf(opts.IO.ErrOut, "\nInstalling to %s...\n", plan.host.Name) + } + + result, err := installer.InstallLocal(&installer.LocalOptions{ + SourceDir: absSource, + Skills: plan.skills, + AgentHost: plan.host, + Scope: scope, + Dir: opts.Dir, + GitRoot: gitRoot, + HomeDir: homeDir, + }) + if err != nil { + return err + } + + for _, name := range result.Installed { + fmt.Fprintf(opts.IO.Out, "Installed %s (from %s) in %s\n", + name, opts.SkillSource, friendlyDir(result.Dir)) + } + + printFileTree(opts.IO.Out, cs, result.Dir, result.Installed) + printReviewHint(opts.IO.ErrOut, cs, "", result.Installed) + } + + return nil +} + +// isSkillPath returns true if the argument looks like a repo-relative path +// rather than a simple skill name. +func isSkillPath(name string) bool { + if name == "" { + return false + } + if name == "SKILL.md" || strings.HasSuffix(name, "/SKILL.md") { + return true + } + if strings.HasPrefix(name, "skills/") || strings.HasPrefix(name, "plugins/") { + return true + } + return false +} + +func resolveRepoArg(skillSource string, canPrompt bool, p prompter.Prompter) (ghrepo.Interface, string, error) { + if skillSource == "" { + if !canPrompt { + return nil, "", cmdutil.FlagErrorf("must specify a repository to install from") + } + repoInput, err := p.Input("Repository (owner/repo):", "") + if err != nil { + return nil, "", err + } + skillSource = strings.TrimSpace(repoInput) + if skillSource == "" { + return nil, "", fmt.Errorf("must specify a repository to install from") + } + } + repo, err := ghrepo.FromFullName(skillSource) + if err != nil { + return nil, "", cmdutil.FlagErrorf("invalid repository reference %q: expected OWNER/REPO, HOST/OWNER/REPO, or a full URL", skillSource) + } + return repo, skillSource, nil +} + +func parseSkillFromOpts(opts *installOptions) { + if opts.SkillName != "" { + if name, version, ok := cutLast(opts.SkillName, "@"); ok && name != "" { + opts.version = version + opts.SkillName = name + return + } + } + if opts.Pin != "" { + opts.version = opts.Pin + } +} + +// cutLast splits s around the last occurrence of sep, +// returning the text before and after sep, and whether sep was found. +func cutLast(s, sep string) (before, after string, found bool) { + if i := strings.LastIndex(s, sep); i >= 0 { + return s[:i], s[i+len(sep):], true + } + return s, "", false +} + +func resolveVersion(opts *installOptions, client discovery.RESTClient, hostname string) (*discovery.ResolvedRef, error) { + opts.IO.StartProgressIndicatorWithLabel("Resolving version") + resolved, err := discovery.ResolveRef(client, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), opts.version) + opts.IO.StopProgressIndicator() + if err != nil { + return nil, fmt.Errorf("could not resolve version: %w", err) + } + fmt.Fprintf(opts.IO.ErrOut, "Using ref %s (%s)\n", resolved.Ref, gitclient.TruncateSHA(resolved.SHA)) + return resolved, nil +} + +func discoverSkills(opts *installOptions, client discovery.RESTClient, hostname string, resolved *discovery.ResolvedRef) ([]discovery.Skill, error) { + opts.IO.StartProgressIndicatorWithLabel("Discovering skills") + skills, err := discovery.DiscoverSkills(client, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), resolved.SHA) + opts.IO.StopProgressIndicator() + if err != nil { + return nil, err + } + logConventions(opts.IO, skills) + for _, s := range skills { + if !discovery.IsSpecCompliant(s.Name) { + fmt.Fprintf(opts.IO.ErrOut, "Warning: skill %q does not follow the agentskills.io naming convention\n", s.DisplayName()) + } + } + sort.Slice(skills, func(i, j int) bool { + return skills[i].DisplayName() < skills[j].DisplayName() + }) + return skills, nil +} + +func logConventions(io *iostreams.IOStreams, skills []discovery.Skill) { + conventions := make(map[string]int) + for _, s := range skills { + conventions[s.Convention]++ + } + if n, ok := conventions["skills-namespaced"]; ok { + fmt.Fprintf(io.ErrOut, "Note: found %d namespaced skill(s) in skills/{author}/ directories\n", n) + } + if n, ok := conventions["plugins"]; ok { + fmt.Fprintf(io.ErrOut, "Note: found %d skill(s) using the Claude Code plugins/ convention\n", n) + } + if n, ok := conventions["root"]; ok { + fmt.Fprintf(io.ErrOut, "Note: found %d skill(s) at the repository root\n", n) + } +} + +// skillSelector holds the callbacks that differ between remote and local skill selection. +type skillSelector struct { + // matchByName resolves a skill name to matching skills. + matchByName func(opts *installOptions, skills []discovery.Skill) ([]discovery.Skill, error) + // sourceHint is shown in collision error guidance (e.g. "owner/repo" or "/path/to/skills"). + sourceHint string + // fetchDescriptions, if non-nil, is called before prompting to pre-populate descriptions. + fetchDescriptions func() +} + +func selectSkillsWithSelector(opts *installOptions, skills []discovery.Skill, canPrompt bool, sel skillSelector) ([]discovery.Skill, error) { + checkCollisions := func(ss []discovery.Skill) error { + return collisionError(ss, sel.sourceHint) + } + + if opts.All { + if err := checkCollisions(skills); err != nil { + return nil, err + } + return skills, nil + } + + if opts.SkillName != "" { + return sel.matchByName(opts, skills) + } + + if !canPrompt { + return nil, cmdutil.FlagErrorf("must specify a skill name or use --all when not running interactively") + } + + if sel.fetchDescriptions != nil { + sel.fetchDescriptions() + } + + tw := opts.IO.TerminalWidth() + descWidth := tw - 35 + if descWidth < 20 { + descWidth = 20 + } + + selected, err := opts.Prompter.MultiSelectWithSearch( + "Select skill(s) to install:", + "Filter skills", + nil, + []string{allSkillsKey}, + skillSearchFunc(skills, descWidth), + ) + if err != nil { + return nil, err + } + + if len(selected) == 0 { + return nil, fmt.Errorf("must select at least one skill") + } + + for _, s := range selected { + if s == allSkillsKey { + if err := checkCollisions(skills); err != nil { + return nil, err + } + return skills, nil + } + } + + result, err := matchSelectedSkills(skills, selected) + if err != nil { + return nil, err + } + return result, checkCollisions(result) +} + +func matchSkillByName(opts *installOptions, skills []discovery.Skill) ([]discovery.Skill, error) { + for _, s := range skills { + if s.DisplayName() == opts.SkillName { + return []discovery.Skill{s}, nil + } + } + + var matches []discovery.Skill + for _, s := range skills { + if s.Name == opts.SkillName { + matches = append(matches, s) + } + } + + switch len(matches) { + case 0: + return nil, fmt.Errorf("skill %q not found in %s", opts.SkillName, ghrepo.FullName(opts.repo)) + case 1: + return matches, nil + default: + names := make([]string, len(matches)) + for i, m := range matches { + names[i] = m.DisplayName() + } + return nil, fmt.Errorf( + "skill name %q is ambiguous — multiple matches found:\n %s\n Specify the full name (e.g. %s) to disambiguate", + opts.SkillName, strings.Join(names, "\n "), names[0], + ) + } +} + +func matchLocalSkillByName(opts *installOptions, skills []discovery.Skill) ([]discovery.Skill, error) { + for _, s := range skills { + if s.DisplayName() == opts.SkillName || s.Name == opts.SkillName { + return []discovery.Skill{s}, nil + } + } + return nil, fmt.Errorf("skill %q not found in local directory", opts.SkillName) +} + +// skillSearchFunc returns a search function for MultiSelectWithSearch that +// filters skills by case-insensitive substring match on name and description. +func skillSearchFunc(skills []discovery.Skill, descWidth int) func(string) prompter.MultiSelectSearchResult { + return func(query string) prompter.MultiSelectSearchResult { + var matched []discovery.Skill + if query == "" { + matched = skills + } else { + q := strings.ToLower(query) + for _, s := range skills { + if strings.Contains(strings.ToLower(s.DisplayName()), q) || + strings.Contains(strings.ToLower(s.Description), q) { + matched = append(matched, s) + } + } + } + + more := 0 + if len(matched) > maxSearchResults { + more = len(matched) - maxSearchResults + matched = matched[:maxSearchResults] + } + + keys := make([]string, len(matched)) + labels := make([]string, len(matched)) + for i, s := range matched { + keys[i] = s.DisplayName() + if s.Description != "" { + labels[i] = fmt.Sprintf("%s — %s", s.DisplayName(), truncateDescription(s.Description, descWidth)) + } else { + labels[i] = s.DisplayName() + } + } + + return prompter.MultiSelectSearchResult{ + Keys: keys, + Labels: labels, + MoreResults: more, + } + } +} + +// matchSelectedSkills maps display names back to skill structs. +func matchSelectedSkills(skills []discovery.Skill, selected []string) ([]discovery.Skill, error) { + nameSet := make(map[string]struct{}, len(selected)) + for _, name := range selected { + nameSet[name] = struct{}{} + } + + var result []discovery.Skill + for _, s := range skills { + if _, ok := nameSet[s.DisplayName()]; ok { + result = append(result, s) + } + } + if len(result) == 0 { + return nil, fmt.Errorf("no matching skills found") + } + return result, nil +} + +// collisionError checks for name collisions and returns an error with +// guidance on how to install skills individually. +func collisionError(ss []discovery.Skill, sourceHint string) error { + collisions := skills.FindNameCollisions(ss) + if len(collisions) == 0 { + return nil + } + return errors.New(heredoc.Docf(` + cannot install skills with conflicting names — they would overwrite each other: + %s + Install these skills individually using the full name: + gh skills install %s namespace/skill-name + `, skills.FormatCollisions(collisions), sourceHint)) +} + +func resolveHosts(opts *installOptions, canPrompt bool) ([]*hosts.Host, error) { + if opts.Agent != "" { + h, err := hosts.FindByID(opts.Agent) + if err != nil { + return nil, err + } + return []*hosts.Host{h}, nil + } + + if !canPrompt { + h, err := hosts.FindByID("github-copilot") + if err != nil { + return nil, err + } + return []*hosts.Host{h}, nil + } + + fmt.Fprintln(opts.IO.ErrOut) + names := hosts.HostNames() + indices, err := opts.Prompter.MultiSelect("Select target agent(s):", []string{names[0]}, names) + if err != nil { + return nil, err + } + + if len(indices) == 0 { + return nil, fmt.Errorf("must select at least one target agent") + } + + selected := make([]*hosts.Host, len(indices)) + for i, idx := range indices { + selected[i] = &hosts.Registry[idx] + } + return selected, nil +} + +func resolveScope(opts *installOptions, canPrompt bool) (hosts.Scope, error) { + if opts.Dir != "" { + return hosts.Scope(opts.Scope), nil + } + + if opts.ScopeChanged || !canPrompt { + return hosts.Scope(opts.Scope), nil + } + + var repoName string + if remote, err := opts.GitClient.RemoteURL("origin"); err == nil { + repoName = hosts.RepoNameFromRemote(remote) + } + idx, err := opts.Prompter.Select("Installation scope:", "", hosts.ScopeLabels(repoName)) + if err != nil { + return "", err + } + if idx == 0 { + return hosts.ScopeProject, nil + } + return hosts.ScopeUser, nil +} + +func truncateDescription(s string, maxWidth int) string { + return text.Truncate(maxWidth, text.RemoveExcessiveWhitespace(s)) +} + +func checkOverwrite(opts *installOptions, skills []discovery.Skill, host *hosts.Host, scope hosts.Scope, gitRoot, homeDir string, canPrompt bool) ([]discovery.Skill, error) { + targetDir := opts.Dir + if targetDir == "" { + var err error + targetDir, err = host.InstallDir(scope, gitRoot, homeDir) + if err != nil { + return nil, err + } + } + + var existing, fresh []discovery.Skill + for _, s := range skills { + dir := filepath.Join(targetDir, filepath.FromSlash(s.InstallName())) + if _, err := os.Stat(dir); err == nil { + existing = append(existing, s) + } else { + fresh = append(fresh, s) + } + } + + if len(existing) == 0 { + return skills, nil + } + + if opts.Force { + return skills, nil + } + + if !canPrompt { + names := make([]string, len(existing)) + for i, s := range existing { + names[i] = s.DisplayName() + } + return nil, fmt.Errorf("skills already installed: %s (use --force to overwrite)", strings.Join(names, ", ")) + } + + var confirmed []discovery.Skill + for _, s := range existing { + prompt := existingSkillPrompt(targetDir, s) + ok, err := opts.Prompter.Confirm(prompt, false) + if err != nil { + return nil, err + } + if ok { + confirmed = append(confirmed, s) + } else { + fmt.Fprintf(opts.IO.ErrOut, "Skipping %s\n", s.DisplayName()) + } + } + + return append(fresh, confirmed...), nil +} + +func existingSkillPrompt(targetDir string, incoming discovery.Skill) string { + skillFile := filepath.Join(targetDir, filepath.FromSlash(incoming.InstallName()), "SKILL.md") + data, err := os.ReadFile(skillFile) + if err != nil { + return fmt.Sprintf("Skill %q already exists. Overwrite?", incoming.DisplayName()) + } + + result, err := frontmatter.Parse(string(data)) + if err != nil || result.Metadata.Meta == nil { + return fmt.Sprintf("Skill %q already exists. Overwrite?", incoming.DisplayName()) + } + + owner, _ := result.Metadata.Meta["github-owner"].(string) + repo, _ := result.Metadata.Meta["github-repo"].(string) + ref, _ := result.Metadata.Meta["github-ref"].(string) + + if owner != "" && repo != "" { + source := owner + "/" + repo + if ref != "" { + source += "@" + ref + } + return fmt.Sprintf("Skill %q already installed from %s. Overwrite?", incoming.DisplayName(), source) + } + + return fmt.Sprintf("Skill %q already exists. Overwrite?", incoming.DisplayName()) +} + +func installProgress(io *iostreams.IOStreams, total int) func(done, total int) { + if total <= 1 { + return nil + } + return func(done, total int) { + if done == 0 { + io.StartProgressIndicator() + } else if done >= total { + io.StopProgressIndicator() + } + } +} + +func friendlyDir(dir string) string { + if cwd, err := os.Getwd(); err == nil { + if rel, err := filepath.Rel(cwd, dir); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + if rel == "." { + return filepath.Base(dir) + } + return rel + } + } + if home, err := os.UserHomeDir(); err == nil && (dir == home || strings.HasPrefix(dir, home+string(filepath.Separator))) { + return "~" + dir[len(home):] + } + return dir +} + +// printFileTree renders a text tree of the on-disk contents of each skill directory. +func printFileTree(w io.Writer, cs *iostreams.ColorScheme, dir string, skillNames []string) { + if len(skillNames) == 0 { + return + } + fmt.Fprintln(w) + for _, name := range skillNames { + skillDir := filepath.Join(dir, filepath.FromSlash(name)) + fmt.Fprintf(w, " %s\n", cs.Bold(name+"/")) + printTreeDir(w, cs, skillDir, " ") + } +} + +func printTreeDir(w io.Writer, cs *iostreams.ColorScheme, dir, indent string) { + entries, err := os.ReadDir(dir) + if err != nil { + fmt.Fprintf(w, "%s%s\n", indent, cs.Gray("(could not read directory)")) + return + } + for i, entry := range entries { + isLast := i == len(entries)-1 + connector := "├── " + childIndent := "│ " + if isLast { + connector = "└── " + childIndent = " " + } + name := entry.Name() + if entry.IsDir() { + fmt.Fprintf(w, "%s%s%s\n", indent, cs.Gray(connector), cs.Bold(name+"/")) + printTreeDir(w, cs, filepath.Join(dir, name), indent+cs.Gray(childIndent)) + } else { + fmt.Fprintf(w, "%s%s%s\n", indent, cs.Gray(connector), name) + } + } +} + +// printReviewHint warns the user to review installed skills and suggests preview commands. +func printReviewHint(w io.Writer, cs *iostreams.ColorScheme, repo string, skillNames []string) { + if len(skillNames) == 0 { + return + } + fmt.Fprintf(w, "\n%s Skills may contain prompt injections or malicious scripts.\n", cs.WarningIcon()) + if repo == "" { + fmt.Fprintln(w, " Review the installed files before use.") + return + } + fmt.Fprintln(w, " Review installed content before use:") + fmt.Fprintln(w) + for _, name := range skillNames { + fmt.Fprintf(w, " gh skills preview %s %s\n", repo, name) + } + fmt.Fprintln(w) +} diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go new file mode 100644 index 000000000..f53fd2267 --- /dev/null +++ b/pkg/cmd/skills/install/install_test.go @@ -0,0 +1,917 @@ +package install + +import ( + "encoding/base64" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/internal/skills/gitclient" + "github.com/cli/cli/v2/internal/skills/hosts" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockGitClient implements installGitClient for testing. +type mockGitClient struct { + root string + remote string + err error +} + +func (m *mockGitClient) ToplevelDir() (string, error) { + if m.err != nil { + return "", m.err + } + return m.root, nil +} + +func (m *mockGitClient) RemoteURL(_ string) (string, error) { + if m.err != nil { + return "", m.err + } + return m.remote, nil +} + +func TestNewCmdInstall_Help(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{}, + } + + cmd := NewCmdInstall(f, func(opts *installOptions) error { + return nil + }) + + assert.Equal(t, "install []", cmd.Use) + assert.NotEmpty(t, cmd.Short) + assert.NotEmpty(t, cmd.Long) + assert.NotEmpty(t, cmd.Example) +} + +func TestNewCmdInstall_Alias(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} + cmd := NewCmdInstall(f, func(_ *installOptions) error { return nil }) + assert.Contains(t, cmd.Aliases, "add") +} + +func TestNewCmdInstall_Flags(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} + cmd := NewCmdInstall(f, func(_ *installOptions) error { return nil }) + + flags := []string{"agent", "scope", "pin", "all", "dir", "force"} + for _, name := range flags { + assert.NotNil(t, cmd.Flags().Lookup(name), "missing flag: --%s", name) + } +} + +func TestNewCmdInstall_MaxArgs(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} + cmd := NewCmdInstall(f, func(_ *installOptions) error { return nil }) + + cmd.SetArgs([]string{"a", "b", "c"}) + err := cmd.Execute() + assert.Error(t, err) +} + +func TestResolveRepoArg(t *testing.T) { + tests := []struct { + input string + owner string + repo string + wantErr bool + }{ + {"github/awesome-copilot", "github", "awesome-copilot", false}, + {"owner/repo", "owner", "repo", false}, + {"a/b", "a", "b", false}, + {"https://github.com/owner/repo", "owner", "repo", false}, + {"https://github.com/owner/repo.git", "owner", "repo", false}, + {"invalid", "", "", true}, + {"", "", "", true}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + repo, _, err := resolveRepoArg(tt.input, false, nil) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.owner, repo.RepoOwner()) + assert.Equal(t, tt.repo, repo.RepoName()) + }) + } +} + +func TestParseSkillFromOpts(t *testing.T) { + tests := []struct { + name string + skillName string + pin string + wantName string + wantVer string + }{ + { + name: "name with version", + skillName: "git-commit@v1.2.0", + wantName: "git-commit", + wantVer: "v1.2.0", + }, + { + name: "name without version", + skillName: "git-commit", + wantName: "git-commit", + wantVer: "", + }, + { + name: "inline version takes precedence over pin", + skillName: "git-commit@v1.0.0", + pin: "v2.0.0", + wantName: "git-commit", + wantVer: "v1.0.0", + }, + { + name: "pin flag alone", + skillName: "git-commit", + pin: "v3.0.0", + wantName: "git-commit", + wantVer: "v3.0.0", + }, + { + name: "empty", + skillName: "", + wantName: "", + wantVer: "", + }, + { + name: "@ at start is not version", + skillName: "@foo", + wantName: "@foo", + wantVer: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := &installOptions{SkillName: tt.skillName, Pin: tt.pin} + parseSkillFromOpts(opts) + assert.Equal(t, tt.wantName, opts.SkillName) + assert.Equal(t, tt.wantVer, opts.version) + }) + } +} + +func TestInstallRun_NonInteractive_NoRepo(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(false) + + opts := &installOptions{ + IO: ios, + GitClient: &mockGitClient{root: "/tmp", remote: ""}, + } + + err := installRun(opts) + assert.Error(t, err) + assert.Equal(t, "must specify a repository to install from", err.Error()) +} + +func TestInstallRun_NonInteractive_NoSkill(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(false) + + opts := &installOptions{IO: ios, repo: ghrepo.New("o", "r")} + skills := []discovery.Skill{{Name: "test-skill", Path: "skills/test-skill"}} + _, err := selectSkillsWithSelector(opts, skills, false, skillSelector{matchByName: matchSkillByName, sourceHint: "REPO"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "must specify a skill name or use --all") +} + +func TestSelectSkills_All(t *testing.T) { + ios, _, _, _ := iostreams.Test() + skills := []discovery.Skill{ + {Name: "a"}, + {Name: "b"}, + } + opts := &installOptions{All: true, IO: ios, repo: ghrepo.New("o", "r")} + got, err := selectSkillsWithSelector(opts, skills, false, skillSelector{matchByName: matchSkillByName, sourceHint: "REPO"}) + require.NoError(t, err) + assert.Len(t, got, 2) +} + +func TestSelectSkills_ByName(t *testing.T) { + ios, _, _, _ := iostreams.Test() + skills := []discovery.Skill{ + {Name: "alpha"}, + {Name: "beta"}, + } + opts := &installOptions{SkillName: "beta", IO: ios, repo: ghrepo.New("o", "r")} + got, err := selectSkillsWithSelector(opts, skills, false, skillSelector{matchByName: matchSkillByName, sourceHint: "REPO"}) + require.NoError(t, err) + assert.Len(t, got, 1) + assert.Equal(t, "beta", got[0].Name) +} + +func TestSelectSkills_NotFound(t *testing.T) { + ios, _, _, _ := iostreams.Test() + skills := []discovery.Skill{ + {Name: "alpha"}, + } + opts := &installOptions{SkillName: "nonexistent", IO: ios, repo: ghrepo.New("o", "r")} + _, err := selectSkillsWithSelector(opts, skills, false, skillSelector{matchByName: matchSkillByName, sourceHint: "REPO"}) + assert.Error(t, err) +} + +func TestSkillSearchFunc_EmptyQuery(t *testing.T) { + skills := []discovery.Skill{ + {Name: "alpha", Description: "first skill"}, + {Name: "beta", Description: "second skill"}, + } + fn := skillSearchFunc(skills, 40) + result := fn("") + assert.Nil(t, result.Err) + assert.Len(t, result.Keys, 2) + assert.Equal(t, "alpha", result.Keys[0]) + assert.Equal(t, "beta", result.Keys[1]) + assert.Equal(t, 0, result.MoreResults) +} + +func TestSkillSearchFunc_FilterByName(t *testing.T) { + skills := []discovery.Skill{ + {Name: "git-commit"}, + {Name: "code-review"}, + {Name: "git-push"}, + } + fn := skillSearchFunc(skills, 40) + result := fn("git") + assert.Nil(t, result.Err) + assert.Len(t, result.Keys, 2) + assert.Equal(t, "git-commit", result.Keys[0]) + assert.Equal(t, "git-push", result.Keys[1]) +} + +func TestSkillSearchFunc_FilterByDescription(t *testing.T) { + skills := []discovery.Skill{ + {Name: "alpha", Description: "handles authentication"}, + {Name: "beta", Description: "builds docker images"}, + } + fn := skillSearchFunc(skills, 40) + result := fn("docker") + assert.Nil(t, result.Err) + assert.Len(t, result.Keys, 1) + assert.Equal(t, "beta", result.Keys[0]) +} + +func TestSkillSearchFunc_CaseInsensitive(t *testing.T) { + skills := []discovery.Skill{ + {Name: "Git-Commit"}, + } + fn := skillSearchFunc(skills, 40) + result := fn("GIT") + assert.Nil(t, result.Err) + assert.Len(t, result.Keys, 1) +} + +func TestSkillSearchFunc_MoreResults(t *testing.T) { + skills := make([]discovery.Skill, 50) + for i := range skills { + skills[i] = discovery.Skill{Name: fmt.Sprintf("skill-%d", i)} + } + fn := skillSearchFunc(skills, 40) + result := fn("") + assert.Equal(t, maxSearchResults, len(result.Keys)) + assert.Equal(t, 50-maxSearchResults, result.MoreResults) +} + +func TestMatchSelectedSkills(t *testing.T) { + skills := []discovery.Skill{ + {Name: "alpha"}, + {Name: "beta"}, + {Name: "gamma"}, + } + got, err := matchSelectedSkills(skills, []string{"alpha", "gamma"}) + require.NoError(t, err) + assert.Len(t, got, 2) + assert.Equal(t, "alpha", got[0].Name) + assert.Equal(t, "gamma", got[1].Name) +} + +func TestMatchSelectedSkills_NoMatch(t *testing.T) { + skills := []discovery.Skill{{Name: "alpha"}} + _, err := matchSelectedSkills(skills, []string{"nonexistent"}) + assert.Error(t, err) +} + +func TestResolveHosts_ByFlag(t *testing.T) { + ios, _, _, _ := iostreams.Test() + opts := &installOptions{Agent: "claude-code", IO: ios} + hosts, err := resolveHosts(opts, false) + require.NoError(t, err) + assert.Len(t, hosts, 1) + assert.Equal(t, "claude-code", hosts[0].ID) +} + +func TestResolveHosts_InvalidFlag(t *testing.T) { + ios, _, _, _ := iostreams.Test() + opts := &installOptions{Agent: "nonexistent", IO: ios} + _, err := resolveHosts(opts, false) + assert.Error(t, err) +} + +func TestResolveHosts_DefaultNonInteractive(t *testing.T) { + ios, _, _, _ := iostreams.Test() + opts := &installOptions{IO: ios} + hosts, err := resolveHosts(opts, false) + require.NoError(t, err) + assert.Len(t, hosts, 1) + assert.Equal(t, "github-copilot", hosts[0].ID) +} + +func TestResolveHosts_MultiSelect(t *testing.T) { + ios, _, _, _ := iostreams.Test() + pm := &prompter.PrompterMock{ + MultiSelectFunc: func(_ string, _ []string, _ []string) ([]int, error) { + return []int{0, 1}, nil + }, + } + opts := &installOptions{IO: ios, Prompter: pm} + hosts, err := resolveHosts(opts, true) + require.NoError(t, err) + assert.Len(t, hosts, 2) +} + +func TestResolveHosts_NoneSelected(t *testing.T) { + ios, _, _, _ := iostreams.Test() + pm := &prompter.PrompterMock{ + MultiSelectFunc: func(_ string, _ []string, _ []string) ([]int, error) { + return []int{}, nil + }, + } + opts := &installOptions{IO: ios, Prompter: pm} + _, err := resolveHosts(opts, true) + assert.Error(t, err) +} + +func TestTruncateSHA(t *testing.T) { + assert.Equal(t, "abc123de", gitclient.TruncateSHA("abc123def456")) + assert.Equal(t, "short", gitclient.TruncateSHA("short")) +} + +func TestTruncateDescription(t *testing.T) { + tests := []struct { + name string + input string + maxWidth int + }{ + {"short stays short", "A short description", 60}, + {"newlines collapsed", "Line one.\nLine two.\nLine three.", 60}, + {"excessive whitespace", " lots of spaces ", 60}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := truncateDescription(tt.input, tt.maxWidth) + assert.NotContains(t, got, "\n") + }) + } + + long := "Execute git commit with conventional commit message analysis and intelligent staging" + got := truncateDescription(long, 30) + assert.LessOrEqual(t, len(got), 33) // allow room for ellipsis +} + +func TestIsLocalPath(t *testing.T) { + tests := []struct { + arg string + want bool + }{ + {".", true}, + {"./skills", true}, + {"../other", true}, + {"/tmp/skills", true}, + {"~/skills", true}, + {"github/awesome-copilot", false}, + {"owner/repo", false}, + {"", false}, + } + for _, tt := range tests { + t.Run(tt.arg, func(t *testing.T) { + got := isLocalPath(tt.arg) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestIsSkillPath(t *testing.T) { + tests := []struct { + name string + want bool + }{ + {"skills/test-skill", true}, + {"skills/author/skill", true}, + {"plugins/author/skills/skill", true}, + {"skills/author/skill/SKILL.md", true}, + {"git-commit", false}, + {"", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isSkillPath(tt.name)) + }) + } +} + +func TestRunLocalInstall_NonInteractive(t *testing.T) { + dir := t.TempDir() + skillDir := filepath.Join(dir, "skills", "test-local") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + content := "---\nname: test-local\ndescription: A local skill\n---\n# Test\n" + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) + + targetDir := t.TempDir() + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetColorEnabled(false) + + opts := &installOptions{ + IO: ios, + SkillSource: dir, + localPath: dir, + All: true, + Force: true, + Agent: "github-copilot", + Scope: "project", + Dir: targetDir, + GitClient: &mockGitClient{root: t.TempDir(), remote: ""}, + } + + err := installRun(opts) + require.NoError(t, err) + + assert.Contains(t, stdout.String(), "Installed test-local") + + installed, err := os.ReadFile(filepath.Join(targetDir, "test-local", "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(installed), "local-path") +} + +func TestRunLocalInstall_SingleSkillDir(t *testing.T) { + dir := t.TempDir() + content := "---\nname: direct-skill\ndescription: Direct\n---\n# Direct\n" + require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644)) + + targetDir := t.TempDir() + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetColorEnabled(false) + + opts := &installOptions{ + IO: ios, + SkillSource: dir, + localPath: dir, + All: true, + Force: true, + Agent: "github-copilot", + Scope: "project", + Dir: targetDir, + GitClient: &mockGitClient{root: t.TempDir(), remote: ""}, + } + + err := installRun(opts) + require.NoError(t, err) + + assert.Contains(t, stdout.String(), "Installed direct-skill") +} + +func TestCollisionError(t *testing.T) { + t.Run("no collisions", func(t *testing.T) { + skills := []discovery.Skill{ + {Name: "a"}, + {Name: "b"}, + } + assert.NoError(t, collisionError(skills, "REPO")) + }) + + t.Run("no collisions with different namespaces", func(t *testing.T) { + skills := []discovery.Skill{ + {Name: "xlsx-pro", Namespace: "author1"}, + {Name: "xlsx-pro", Namespace: "author2"}, + } + assert.NoError(t, collisionError(skills, "REPO")) + }) + + t.Run("has collisions same name no namespace", func(t *testing.T) { + skills := []discovery.Skill{ + {Name: "xlsx-pro", Convention: "skills"}, + {Name: "xlsx-pro", Convention: "root"}, + } + err := collisionError(skills, "REPO") + assert.Error(t, err) + assert.Contains(t, err.Error(), "conflicting names") + assert.Contains(t, err.Error(), "gh skills install REPO") + }) + + t.Run("local source hint", func(t *testing.T) { + skills := []discovery.Skill{ + {Name: "xlsx-pro", Convention: "skills"}, + {Name: "xlsx-pro", Convention: "root"}, + } + err := collisionError(skills, "PATH") + assert.Error(t, err) + assert.Contains(t, err.Error(), "conflicting names") + assert.Contains(t, err.Error(), "gh skills install PATH") + }) +} + +func TestMatchSkillByName_Ambiguous(t *testing.T) { + ios, _, _, _ := iostreams.Test() + skills := []discovery.Skill{ + {Name: "xlsx-pro", Namespace: "alice"}, + {Name: "xlsx-pro", Namespace: "bob"}, + } + opts := &installOptions{SkillName: "xlsx-pro", IO: ios, repo: ghrepo.New("o", "r")} + _, err := matchSkillByName(opts, skills) + assert.Error(t, err) + assert.Contains(t, err.Error(), "ambiguous") +} + +func TestMatchSkillByName_NamespacedExact(t *testing.T) { + ios, _, _, _ := iostreams.Test() + skills := []discovery.Skill{ + {Name: "xlsx-pro", Namespace: "alice"}, + {Name: "xlsx-pro", Namespace: "bob"}, + } + opts := &installOptions{SkillName: "bob/xlsx-pro", IO: ios, repo: ghrepo.New("o", "r")} + got, err := matchSkillByName(opts, skills) + require.NoError(t, err) + assert.Len(t, got, 1) + assert.Equal(t, "bob", got[0].Namespace) +} + +func TestFriendlyDir(t *testing.T) { + // Test home directory path + home, err := os.UserHomeDir() + require.NoError(t, err) + got := friendlyDir(filepath.Join(home, ".github", "skills")) + assert.True(t, strings.HasPrefix(got, "~"), "expected ~ prefix, got %q", got) +} + +func TestResolveScope_ExplicitFlag(t *testing.T) { + ios, _, _, _ := iostreams.Test() + opts := &installOptions{ + IO: ios, + Scope: "user", + ScopeChanged: true, + GitClient: &mockGitClient{root: "/tmp", remote: ""}, + } + scope, err := resolveScope(opts, true) + require.NoError(t, err) + assert.Equal(t, "user", string(scope)) +} + +func TestResolveScope_DirBypasses(t *testing.T) { + ios, _, _, _ := iostreams.Test() + opts := &installOptions{ + IO: ios, + Dir: "/tmp/custom", + Scope: "project", + GitClient: &mockGitClient{root: "/tmp", remote: ""}, + } + scope, err := resolveScope(opts, true) + require.NoError(t, err) + assert.Equal(t, "project", string(scope)) +} + +func TestCheckOverwrite_NoExisting(t *testing.T) { + ios, _, _, _ := iostreams.Test() + targetDir := t.TempDir() + skills := []discovery.Skill{{Name: "new-skill"}} + host := &hosts.Host{ID: "test", ProjectDir: "skills"} + opts := &installOptions{IO: ios, Dir: targetDir} + + got, err := checkOverwrite(opts, skills, host, hosts.ScopeProject, "/tmp", "/home", false) + require.NoError(t, err) + assert.Len(t, got, 1) +} + +func TestCheckOverwrite_ExistingWithForce(t *testing.T) { + targetDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "existing-skill"), 0o755)) + + ios, _, _, _ := iostreams.Test() + skills := []discovery.Skill{{Name: "existing-skill"}} + host := &hosts.Host{ID: "test", ProjectDir: "skills"} + opts := &installOptions{IO: ios, Dir: targetDir, Force: true} + + got, err := checkOverwrite(opts, skills, host, hosts.ScopeProject, "/tmp", "/home", false) + require.NoError(t, err) + assert.Len(t, got, 1) +} + +func TestCheckOverwrite_ExistingNonInteractive(t *testing.T) { + targetDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "existing-skill"), 0o755)) + + ios, _, _, _ := iostreams.Test() + skills := []discovery.Skill{{Name: "existing-skill"}} + host := &hosts.Host{ID: "test", ProjectDir: "skills"} + opts := &installOptions{IO: ios, Dir: targetDir} + + _, err := checkOverwrite(opts, skills, host, hosts.ScopeProject, "/tmp", "/home", false) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already installed") +} + +func TestNewCmdInstall(t *testing.T) { + tests := []struct { + name string + input string + wantOpts installOptions + wantErr bool + }{ + { + name: "repo argument only", + input: "owner/repo", + wantOpts: installOptions{SkillSource: "owner/repo", Scope: "project"}, + }, + { + name: "repo and skill", + input: "owner/repo my-skill", + wantOpts: installOptions{SkillSource: "owner/repo", SkillName: "my-skill", Scope: "project"}, + }, + { + name: "with all flags", + input: "owner/repo my-skill --agent github-copilot --scope user --pin v1.0.0 --force", + wantOpts: installOptions{SkillSource: "owner/repo", SkillName: "my-skill", Agent: "github-copilot", Scope: "user", Pin: "v1.0.0", Force: true}, + }, + { + name: "all flag", + input: "owner/repo --all", + wantOpts: installOptions{SkillSource: "owner/repo", All: true, Scope: "project"}, + }, + { + name: "dir flag", + input: "owner/repo my-skill --dir /tmp/skills", + wantOpts: installOptions{SkillSource: "owner/repo", SkillName: "my-skill", Dir: "/tmp/skills", Scope: "project"}, + }, + { + name: "too many args", + input: "a b c", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{}, + } + + var gotOpts *installOptions + cmd := NewCmdInstall(f, func(opts *installOptions) error { + gotOpts = opts + return nil + }) + + args, err := shlex.Split(tt.input) + require.NoError(t, err) + cmd.SetArgs(args) + cmd.SetIn(&strings.Reader{}) + cmd.SetOut(&strings.Builder{}) + cmd.SetErr(&strings.Builder{}) + + _, err = cmd.ExecuteC() + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantOpts.SkillSource, gotOpts.SkillSource) + assert.Equal(t, tt.wantOpts.SkillName, gotOpts.SkillName) + assert.Equal(t, tt.wantOpts.Agent, gotOpts.Agent) + assert.Equal(t, tt.wantOpts.Scope, gotOpts.Scope) + assert.Equal(t, tt.wantOpts.Pin, gotOpts.Pin) + assert.Equal(t, tt.wantOpts.Dir, gotOpts.Dir) + assert.Equal(t, tt.wantOpts.All, gotOpts.All) + assert.Equal(t, tt.wantOpts.Force, gotOpts.Force) + }) + } +} + +func TestInstallRun_RemoteInstall(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + skillContent := "---\nname: test-skill\ndescription: A test\n---\n# Test\n" + encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent)) + + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(`{"sha": "abc123", "tree": [{"path": "skills/test-skill", "type": "tree", "sha": "treeSHA"}, {"path": "skills/test-skill/SKILL.md", "type": "blob", "sha": "blobSHA"}]}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA"), + httpmock.StringResponse(`{"tree": [{"path": "SKILL.md", "type": "blob", "sha": "blobSHA", "size": 50}]}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blobSHA"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blobSHA", "content": "%s", "encoding": "base64"}`, encodedContent)), + ) + + targetDir := t.TempDir() + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetColorEnabled(false) + + opts := &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &mockGitClient{root: t.TempDir(), remote: ""}, + SkillSource: "owner/repo", + SkillName: "test-skill", + Agent: "github-copilot", + Scope: "project", + Dir: targetDir, + } + + defer reg.Verify(t) + err := installRun(opts) + require.NoError(t, err) + + assert.Contains(t, stdout.String(), "Installed test-skill") + + installed, readErr := os.ReadFile(filepath.Join(targetDir, "test-skill", "SKILL.md")) + require.NoError(t, readErr) + assert.Contains(t, string(installed), "github-owner: owner") + assert.Contains(t, string(installed), "github-repo: repo") +} + +func TestPrintFileTree(t *testing.T) { + dir := t.TempDir() + skillDir := filepath.Join(dir, "my-skill") + require.NoError(t, os.MkdirAll(filepath.Join(skillDir, "scripts"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# test"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "scripts", "run.sh"), []byte("#!/bin/bash"), 0o644)) + + ios, _, stdout, _ := iostreams.Test() + cs := ios.ColorScheme() + + printFileTree(stdout, cs, dir, []string{"my-skill"}) + + out := stdout.String() + assert.Contains(t, out, "my-skill/") + assert.Contains(t, out, "SKILL.md") + assert.Contains(t, out, "scripts/") + assert.Contains(t, out, "run.sh") +} + +func TestPrintFileTree_Empty(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + cs := ios.ColorScheme() + + printFileTree(stdout, cs, t.TempDir(), nil) + assert.Empty(t, stdout.String()) +} + +func TestPrintTreeDir_Unreadable(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + cs := ios.ColorScheme() + + printTreeDir(stdout, cs, filepath.Join(t.TempDir(), "nonexistent"), " ") + assert.Contains(t, stdout.String(), "(could not read directory)") +} + +func TestPrintReviewHint_Remote(t *testing.T) { + ios, _, _, stderr := iostreams.Test() + cs := ios.ColorScheme() + + printReviewHint(stderr, cs, "owner/repo", []string{"my-skill", "other-skill"}) + + out := stderr.String() + assert.Contains(t, out, "prompt injections or malicious scripts") + assert.Contains(t, out, "gh skills preview owner/repo my-skill") + assert.Contains(t, out, "gh skills preview owner/repo other-skill") +} + +func TestPrintReviewHint_Local(t *testing.T) { + ios, _, _, stderr := iostreams.Test() + cs := ios.ColorScheme() + + printReviewHint(stderr, cs, "", []string{"my-skill"}) + + out := stderr.String() + assert.Contains(t, out, "prompt injections or malicious scripts") + assert.Contains(t, out, "Review the installed files before use.") + assert.NotContains(t, out, "gh skills preview") +} + +func TestPrintReviewHint_Empty(t *testing.T) { + ios, _, _, stderr := iostreams.Test() + cs := ios.ColorScheme() + + printReviewHint(stderr, cs, "owner/repo", nil) + assert.Empty(t, stderr.String()) +} + +func TestSelectSkills_AllWithNamespacedSkills(t *testing.T) { + ios, _, _, _ := iostreams.Test() + skills := []discovery.Skill{ + {Name: "xlsx-pro", Namespace: "alice", Convention: "skills-namespaced"}, + {Name: "xlsx-pro", Namespace: "bob", Convention: "skills-namespaced"}, + {Name: "other-skill", Convention: "skills"}, + } + opts := &installOptions{All: true, IO: ios, repo: ghrepo.New("o", "r")} + got, err := selectSkillsWithSelector(opts, skills, false, skillSelector{matchByName: matchSkillByName, sourceHint: "REPO"}) + require.NoError(t, err) + assert.Len(t, got, 3) +} + +func TestRunLocalInstall_NamespacedSkills(t *testing.T) { + dir := t.TempDir() + + // Create two skills with the same name under different namespaces + for _, ns := range []string{"alice", "bob"} { + skillDir := filepath.Join(dir, "skills", ns, "xlsx-pro") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + content := fmt.Sprintf("---\nname: xlsx-pro\ndescription: %s xlsx-pro\n---\n# Test\n", ns) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) + } + + targetDir := t.TempDir() + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetColorEnabled(false) + + opts := &installOptions{ + IO: ios, + SkillSource: dir, + localPath: dir, + All: true, + Force: true, + Agent: "github-copilot", + Scope: "project", + Dir: targetDir, + GitClient: &mockGitClient{root: t.TempDir(), remote: ""}, + } + + err := installRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "Installed alice/xlsx-pro") + assert.Contains(t, out, "Installed bob/xlsx-pro") + + // Both should be installed in separate directories + _, err = os.Stat(filepath.Join(targetDir, "alice", "xlsx-pro", "SKILL.md")) + assert.NoError(t, err, "alice/xlsx-pro should be installed") + _, err = os.Stat(filepath.Join(targetDir, "bob", "xlsx-pro", "SKILL.md")) + assert.NoError(t, err, "bob/xlsx-pro should be installed") +} + +func TestCheckOverwrite_NamespacedSkill(t *testing.T) { + ios, _, _, _ := iostreams.Test() + targetDir := t.TempDir() + + // Pre-create a namespaced skill directory + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "alice", "xlsx-pro"), 0o755)) + + skills := []discovery.Skill{ + {Name: "xlsx-pro", Namespace: "alice"}, + {Name: "xlsx-pro", Namespace: "bob"}, + } + host := &hosts.Host{ID: "test", ProjectDir: "skills"} + opts := &installOptions{IO: ios, Dir: targetDir, Force: true} + + got, err := checkOverwrite(opts, skills, host, hosts.ScopeProject, "/tmp", "/home", false) + require.NoError(t, err) + assert.Len(t, got, 2, "both skills should be installable (force mode)") +} diff --git a/pkg/cmd/skills/install/install_windows_test.go b/pkg/cmd/skills/install/install_windows_test.go new file mode 100644 index 000000000..8a184fac4 --- /dev/null +++ b/pkg/cmd/skills/install/install_windows_test.go @@ -0,0 +1,63 @@ +//go:build windows + +package install + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsLocalPath_Windows(t *testing.T) { + tests := []struct { + name string + arg string + want bool + }{ + // Backslash-relative paths that only exist on Windows. + {`dot-backslash prefix`, `.\skills`, true}, + {`dotdot-backslash prefix`, `..\other`, true}, + {`drive-absolute path`, `C:\Users\me\skills`, true}, + {`drive-relative path`, `D:\projects`, true}, + {`UNC path`, `\\server\share\skills`, true}, + + // Forward-slash forms should still work on Windows. + {`dot-slash prefix`, `./skills`, true}, + {`dotdot-slash prefix`, `../other`, true}, + {`current dir`, `.`, true}, + {`absolute unix-style`, `/tmp/skills`, true}, + {`tilde prefix`, `~/skills`, true}, + + // owner/repo should never be treated as local. + {`owner-repo`, `github/awesome-copilot`, false}, + {`simple name`, `awesome-copilot`, false}, + {`empty string`, ``, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isLocalPath(tt.arg) + assert.Equal(t, tt.want, got, "isLocalPath(%q)", tt.arg) + }) + } +} + +func TestIsLocalPath_WindowsExistingDir(t *testing.T) { + // A directory that exists on disk should be detected as local even when + // its name looks like owner/repo (the os.Stat safety-net). + dir := t.TempDir() + nested := filepath.Join(dir, "owner", "repo") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + + // Use a relative path that happens to contain a backslash separator. + rel, err := filepath.Rel(".", nested) + if err != nil { + // If we can't compute a relative path, just use the absolute one. + rel = nested + } + assert.True(t, isLocalPath(rel), "existing dir should be detected as local: %s", rel) +} diff --git a/pkg/cmd/skills/skills.go b/pkg/cmd/skills/skills.go index e3f1c286f..61afc12a4 100644 --- a/pkg/cmd/skills/skills.go +++ b/pkg/cmd/skills/skills.go @@ -1,6 +1,7 @@ package skills import ( + "github.com/cli/cli/v2/pkg/cmd/skills/install" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -14,5 +15,7 @@ func NewCmdSkills(f *cmdutil.Factory) *cobra.Command { GroupID: "core", } + cmd.AddCommand(install.NewCmdInstall(f, nil)) + return cmd } From 40b2a784e3cddbf1419df4aa24b9ac4a6d44b518 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Mon, 30 Mar 2026 21:35:23 +0100 Subject: [PATCH 04/27] add core logic and improve test coverage --- .../skills/skills-install-alias.txtar | 3 - .../skills/skills-install-conflict.txtar | 8 - .../skills/skills-install-invalid-agent.txtar | 4 + .../skills/skills-install-invalid-repo.txtar | 3 + .../skills/skills-install-nested-files.txtar | 3 + .../skills-install-nonexistent-skill.txtar | 3 + .../skills/skills-install-scope.txtar | 12 +- .../testdata/skills/skills-install.txtar | 10 + .../skills-preview-noninteractive.txtar | 3 + .../testdata/skills/skills-preview.txtar | 9 + .../skills/skills-publish-dry-run.txtar | 33 + .../skills/skills-publish-lifecycle.txtar | 64 + .../skills/skills-search-noresults.txtar | 4 + .../testdata/skills/skills-search-page.txtar | 3 + .../testdata/skills/skills-search.txtar | 12 + .../skills/skills-update-noinstalled.txtar | 5 + .../testdata/skills/skills-update.txtar | 24 + git/client.go | 31 + internal/skills/{ => discovery}/collisions.go | 8 +- internal/skills/discovery/collisions_test.go | 62 + internal/skills/discovery/discovery.go | 147 +- internal/skills/discovery/discovery_test.go | 342 ++++- internal/skills/gitclient/gitclient.go | 149 -- internal/skills/gitclient/gitclient_test.go | 49 - internal/skills/hosts/hosts_test.go | 113 -- internal/skills/installer/installer.go | 55 +- internal/skills/installer/installer_test.go | 338 +++++ internal/skills/lockfile/lockfile.go | 42 +- internal/skills/lockfile/lockfile_test.go | 193 +++ .../{hosts/hosts.go => registry/registry.go} | 62 +- internal/skills/registry/registry_test.go | 153 ++ pkg/cmd/skills/install/install.go | 108 +- pkg/cmd/skills/install/install_test.go | 59 +- pkg/cmd/skills/preview/preview.go | 382 +++++ pkg/cmd/skills/preview/preview_test.go | 466 ++++++ pkg/cmd/skills/publish/publish.go | 1246 +++++++++++++++++ pkg/cmd/skills/publish/publish_test.go | 1059 ++++++++++++++ pkg/cmd/skills/search/search.go | 873 ++++++++++++ pkg/cmd/skills/search/search_test.go | 423 ++++++ pkg/cmd/skills/skills.go | 8 + pkg/cmd/skills/update/update.go | 560 ++++++++ pkg/cmd/skills/update/update_test.go | 391 ++++++ 42 files changed, 6849 insertions(+), 673 deletions(-) delete mode 100644 acceptance/testdata/skills/skills-install-alias.txtar delete mode 100644 acceptance/testdata/skills/skills-install-conflict.txtar create mode 100644 acceptance/testdata/skills/skills-install-invalid-agent.txtar create mode 100644 acceptance/testdata/skills/skills-install-invalid-repo.txtar create mode 100644 acceptance/testdata/skills/skills-install-nested-files.txtar create mode 100644 acceptance/testdata/skills/skills-install-nonexistent-skill.txtar create mode 100644 acceptance/testdata/skills/skills-preview-noninteractive.txtar create mode 100644 acceptance/testdata/skills/skills-preview.txtar create mode 100644 acceptance/testdata/skills/skills-publish-dry-run.txtar create mode 100644 acceptance/testdata/skills/skills-publish-lifecycle.txtar create mode 100644 acceptance/testdata/skills/skills-search-noresults.txtar create mode 100644 acceptance/testdata/skills/skills-search-page.txtar create mode 100644 acceptance/testdata/skills/skills-search.txtar create mode 100644 acceptance/testdata/skills/skills-update-noinstalled.txtar create mode 100644 acceptance/testdata/skills/skills-update.txtar rename internal/skills/{ => discovery}/collisions.go (89%) create mode 100644 internal/skills/discovery/collisions_test.go delete mode 100644 internal/skills/gitclient/gitclient.go delete mode 100644 internal/skills/gitclient/gitclient_test.go delete mode 100644 internal/skills/hosts/hosts_test.go create mode 100644 internal/skills/installer/installer_test.go create mode 100644 internal/skills/lockfile/lockfile_test.go rename internal/skills/{hosts/hosts.go => registry/registry.go} (72%) create mode 100644 internal/skills/registry/registry_test.go create mode 100644 pkg/cmd/skills/preview/preview.go create mode 100644 pkg/cmd/skills/preview/preview_test.go create mode 100644 pkg/cmd/skills/publish/publish.go create mode 100644 pkg/cmd/skills/publish/publish_test.go create mode 100644 pkg/cmd/skills/search/search.go create mode 100644 pkg/cmd/skills/search/search_test.go create mode 100644 pkg/cmd/skills/update/update.go create mode 100644 pkg/cmd/skills/update/update_test.go diff --git a/acceptance/testdata/skills/skills-install-alias.txtar b/acceptance/testdata/skills/skills-install-alias.txtar deleted file mode 100644 index 089474b3a..000000000 --- a/acceptance/testdata/skills/skills-install-alias.txtar +++ /dev/null @@ -1,3 +0,0 @@ -# Install with "add" alias -exec gh skills add github/awesome-copilot git-commit --scope user --force --agent github-copilot -stdout 'Installed git-commit' diff --git a/acceptance/testdata/skills/skills-install-conflict.txtar b/acceptance/testdata/skills/skills-install-conflict.txtar deleted file mode 100644 index 9e79e5a5e..000000000 --- a/acceptance/testdata/skills/skills-install-conflict.txtar +++ /dev/null @@ -1,8 +0,0 @@ -# Install --all should handle skills with same name across conventions -# (skills/ and plugins/ directories) without collision errors -exec gh skills install github/awesome-copilot --all --force --dir $WORK/scope-test --agent github-copilot -stdout 'Installed' -! stderr 'conflicting names' - -# Verify skills were installed successfully -exists $WORK/scope-test/git-commit/SKILL.md diff --git a/acceptance/testdata/skills/skills-install-invalid-agent.txtar b/acceptance/testdata/skills/skills-install-invalid-agent.txtar new file mode 100644 index 000000000..23883524f --- /dev/null +++ b/acceptance/testdata/skills/skills-install-invalid-agent.txtar @@ -0,0 +1,4 @@ +# Invalid agent ID should error with valid options +! exec gh skills install github/awesome-copilot git-commit --agent bogus-agent --force +stderr 'unknown agent' +stderr 'github-copilot' diff --git a/acceptance/testdata/skills/skills-install-invalid-repo.txtar b/acceptance/testdata/skills/skills-install-invalid-repo.txtar new file mode 100644 index 000000000..26ecbc718 --- /dev/null +++ b/acceptance/testdata/skills/skills-install-invalid-repo.txtar @@ -0,0 +1,3 @@ +# Nonexistent repo should error +! exec gh skills install nonexistent-owner-xyz/nonexistent-repo-abc --force --dir $WORK/tmp +stderr 'Not Found' diff --git a/acceptance/testdata/skills/skills-install-nested-files.txtar b/acceptance/testdata/skills/skills-install-nested-files.txtar new file mode 100644 index 000000000..c5cf19e56 --- /dev/null +++ b/acceptance/testdata/skills/skills-install-nested-files.txtar @@ -0,0 +1,3 @@ +# Install a skill that has nested subdirectories and verify file tree +exec gh skills install github/awesome-copilot git-commit --force --dir $WORK/nested-test +exists $WORK/nested-test/git-commit/SKILL.md diff --git a/acceptance/testdata/skills/skills-install-nonexistent-skill.txtar b/acceptance/testdata/skills/skills-install-nonexistent-skill.txtar new file mode 100644 index 000000000..23f72cee8 --- /dev/null +++ b/acceptance/testdata/skills/skills-install-nonexistent-skill.txtar @@ -0,0 +1,3 @@ +# Installing a skill that doesn't exist in a valid repo should error +! exec gh skills install github/awesome-copilot nonexistent-skill-xyz --force --dir $WORK/tmp +stderr 'not found' diff --git a/acceptance/testdata/skills/skills-install-scope.txtar b/acceptance/testdata/skills/skills-install-scope.txtar index 9b8048ab5..da2df19ea 100644 --- a/acceptance/testdata/skills/skills-install-scope.txtar +++ b/acceptance/testdata/skills/skills-install-scope.txtar @@ -1,9 +1,9 @@ -# Install with --scope project (default) inside a git repo -exec git init $WORK/myrepo -exec gh skills install github/awesome-copilot git-commit --scope project --force --agent github-copilot --dir $WORK/myrepo/.github/skills -stdout 'Installed git-commit' +# Install with --scope project writes to the git repo's .github/skills/ +exec git init --initial-branch=main $WORK/myrepo +cd $WORK/myrepo +exec gh skills install github/awesome-copilot git-commit --scope project --force --agent github-copilot exists $WORK/myrepo/.github/skills/git-commit/SKILL.md -# Install with --scope user +# Install with --scope user writes to home directory exec gh skills install github/awesome-copilot git-commit --scope user --force --agent github-copilot -stdout 'Installed git-commit' +exists $HOME/.copilot/skills/git-commit/SKILL.md diff --git a/acceptance/testdata/skills/skills-install.txtar b/acceptance/testdata/skills/skills-install.txtar index c04ced9d2..183f930fd 100644 --- a/acceptance/testdata/skills/skills-install.txtar +++ b/acceptance/testdata/skills/skills-install.txtar @@ -2,9 +2,19 @@ exec gh skills install github/awesome-copilot git-commit --scope user --force --agent github-copilot stdout 'Installed git-commit' +# Verify SKILL.md has frontmatter metadata injected +exists $HOME/.copilot/skills/git-commit/SKILL.md +grep 'github-owner' $HOME/.copilot/skills/git-commit/SKILL.md +grep 'github-repo' $HOME/.copilot/skills/git-commit/SKILL.md + +# Verify lockfile was written +exists $HOME/.agents/.skill-lock.json +grep 'git-commit' $HOME/.agents/.skill-lock.json + # Install with --dir to a custom directory exec gh skills install github/awesome-copilot git-commit --force --dir $WORK/custom-skills stdout 'Installed git-commit' # Verify the skill was written to the custom directory exists $WORK/custom-skills/git-commit/SKILL.md +grep 'github-owner' $WORK/custom-skills/git-commit/SKILL.md diff --git a/acceptance/testdata/skills/skills-preview-noninteractive.txtar b/acceptance/testdata/skills/skills-preview-noninteractive.txtar new file mode 100644 index 000000000..939df0ab6 --- /dev/null +++ b/acceptance/testdata/skills/skills-preview-noninteractive.txtar @@ -0,0 +1,3 @@ +# Preview with repo only and non-interactive should error +! exec gh skills preview github/awesome-copilot +stderr 'must specify a skill name' diff --git a/acceptance/testdata/skills/skills-preview.txtar b/acceptance/testdata/skills/skills-preview.txtar new file mode 100644 index 000000000..3834c340c --- /dev/null +++ b/acceptance/testdata/skills/skills-preview.txtar @@ -0,0 +1,9 @@ +# Preview renders skill content and file tree +exec gh skills preview github/awesome-copilot git-commit +stdout 'SKILL.md' +# Verify actual content is rendered, not just the filename +stdout 'git-commit/' + +# Preview a skill that doesn't exist should error +! exec gh skills preview github/awesome-copilot nonexistent-skill-xyz +stderr 'not found' diff --git a/acceptance/testdata/skills/skills-publish-dry-run.txtar b/acceptance/testdata/skills/skills-publish-dry-run.txtar new file mode 100644 index 000000000..39c0f234d --- /dev/null +++ b/acceptance/testdata/skills/skills-publish-dry-run.txtar @@ -0,0 +1,33 @@ +# Publish dry-run from a directory with no skills/ should fail gracefully +! exec gh skills publish --dry-run $WORK +stderr 'no skills/ directory found' + +# Publish dry-run against a valid skill directory should succeed +exec gh skills publish --dry-run $WORK/test-repo +stdout 'hello-world' + +# Validate alias should work identically +exec gh skills validate --dry-run $WORK/test-repo +stdout 'hello-world' + +# Publish dry-run with --tag +exec gh skills publish --dry-run --tag v1.0.0 $WORK/test-repo +stdout 'hello-world' + +# Publish dry-run with --fix +exec gh skills publish --dry-run --fix $WORK/test-repo +stdout 'hello-world' + +-- test-repo/skills/hello-world/SKILL.md -- +--- +name: hello-world +description: A test skill that greets the user. +--- + +# Hello World + +Greet the user warmly. + +-- test-repo/skills/hello-world/scripts/setup.sh -- +#!/bin/bash +echo "Hello from the hello-world skill!" diff --git a/acceptance/testdata/skills/skills-publish-lifecycle.txtar b/acceptance/testdata/skills/skills-publish-lifecycle.txtar new file mode 100644 index 000000000..0e8a03a1d --- /dev/null +++ b/acceptance/testdata/skills/skills-publish-lifecycle.txtar @@ -0,0 +1,64 @@ +# Full publish lifecycle: create repo, publish, install from it, clean up + +# Use gh as a credential helper +exec gh auth setup-git + +# Create a private repo for testing +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --private --add-readme +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING +cd $SCRIPT_NAME-$RANDOM_STRING + +# Add a test skill +mkdir skills/hello-world/scripts +cp $WORK/skill.md skills/hello-world/SKILL.md +cp $WORK/setup.sh skills/hello-world/scripts/setup.sh +exec git add -A +exec git commit -m 'Add test skill' +exec git push origin main + +# Publish with a tag +exec gh skills publish --tag v0.1.0 + +# Verify the release was created on GitHub +exec gh release view v0.1.0 +stdout 'v0.1.0' + +# Install from our test repo +exec gh skills install $ORG/$SCRIPT_NAME-$RANDOM_STRING hello-world --scope user --force +stdout 'Installed hello-world' + +# Verify installed files exist with correct metadata +exists $HOME/.copilot/skills/hello-world/SKILL.md +exists $HOME/.copilot/skills/hello-world/scripts/setup.sh +grep 'github-owner' $HOME/.copilot/skills/hello-world/SKILL.md + +# Install with --pin +exec gh skills install $ORG/$SCRIPT_NAME-$RANDOM_STRING hello-world --scope user --force --pin v0.1.0 +stdout 'Installed hello-world' + +# Preview from our test repo +exec gh skills preview $ORG/$SCRIPT_NAME-$RANDOM_STRING hello-world +stdout 'Hello World' + +# Update dry-run should find installed skill +exec gh skills update --dry-run --all +stderr 'up to date' + +-- skill.md -- +--- +name: hello-world +description: A test skill that greets the user. +--- + +# Hello World + +Greet the user warmly and offer to run the setup script. + +-- setup.sh -- +#!/bin/bash +echo "Hello from the hello-world skill!" +echo "Setting up environment..." +echo "Done." diff --git a/acceptance/testdata/skills/skills-search-noresults.txtar b/acceptance/testdata/skills/skills-search-noresults.txtar new file mode 100644 index 000000000..31f8293f0 --- /dev/null +++ b/acceptance/testdata/skills/skills-search-noresults.txtar @@ -0,0 +1,4 @@ +# Search for something unlikely to exist returns empty stdout +# (NoResultsError is silent in non-TTY — exits 0 with no output) +exec gh skills search zzzznonexistenttotallyfakeskillxyz123 +! stdout . diff --git a/acceptance/testdata/skills/skills-search-page.txtar b/acceptance/testdata/skills/skills-search-page.txtar new file mode 100644 index 000000000..71bc6f1de --- /dev/null +++ b/acceptance/testdata/skills/skills-search-page.txtar @@ -0,0 +1,3 @@ +# Pagination returns results on page 2 +exec gh skills search copilot --page 2 +stdout 'copilot' diff --git a/acceptance/testdata/skills/skills-search.txtar b/acceptance/testdata/skills/skills-search.txtar new file mode 100644 index 000000000..eb4759a41 --- /dev/null +++ b/acceptance/testdata/skills/skills-search.txtar @@ -0,0 +1,12 @@ +# Search for skills matching a query +exec gh skills search copilot +stdout 'copilot' + +# Search with JSON output +exec gh skills search copilot --json skillName,repo --limit 1 +stdout '"skillName"' +stdout '"repo"' + +# Search with a short query should error +! exec gh skills search a +stderr 'at least' diff --git a/acceptance/testdata/skills/skills-update-noinstalled.txtar b/acceptance/testdata/skills/skills-update-noinstalled.txtar new file mode 100644 index 000000000..7f24291ba --- /dev/null +++ b/acceptance/testdata/skills/skills-update-noinstalled.txtar @@ -0,0 +1,5 @@ +# Update with no installed skills should report appropriately +exec gh skills update --dry-run --all --dir $WORK/empty-dir +stderr 'No installed skills found' + +-- empty-dir/.gitkeep -- diff --git a/acceptance/testdata/skills/skills-update.txtar b/acceptance/testdata/skills/skills-update.txtar new file mode 100644 index 000000000..7041c84b4 --- /dev/null +++ b/acceptance/testdata/skills/skills-update.txtar @@ -0,0 +1,24 @@ +# Dry-run update should find the installed skill and report status +exec gh skills update --dry-run --all --dir $WORK/skills-dir +stderr 'update' +stdout 'git-commit' + +# Force update should re-download and rewrite files +exec gh skills update --force --all --dir $WORK/skills-dir +stdout 'Updated' + +# Verify the SKILL.md was rewritten with real content (not our placeholder) +grep 'github-owner' $WORK/skills-dir/git-commit/SKILL.md +! grep 'Test skill content' $WORK/skills-dir/git-commit/SKILL.md + +-- skills-dir/git-commit/SKILL.md -- +--- +name: git-commit +description: Git commit helper +metadata: + github-owner: github + github-repo: awesome-copilot + github-tree-sha: 0000000000000000000000000000000000000000 + github-path: skills/git-commit +--- +Test skill content diff --git a/git/client.go b/git/client.go index 5f547c99c..22c4eff16 100644 --- a/git/client.go +++ b/git/client.go @@ -713,6 +713,37 @@ func (c *Client) IsLocalGitRepo(ctx context.Context) (bool, error) { return true, nil } +// RemoteURL returns the fetch URL configured for the named remote. +func (c *Client) RemoteURL(ctx context.Context, name string) (string, error) { + cmd, err := c.Command(ctx, "remote", "get-url", name) + if err != nil { + return "", err + } + out, err := cmd.Output() + if err != nil { + return "", err + } + return firstLine(out), nil +} + +// IsIgnored reports whether the given path is ignored by .gitignore rules. +func (c *Client) IsIgnored(ctx context.Context, path string) bool { + cmd, err := c.Command(ctx, "check-ignore", "-q", path) + if err != nil { + return false + } + _, err = cmd.Output() + return err == nil +} + +// ShortSHA returns the first 8 characters of a SHA hash for display purposes. +func ShortSHA(sha string) string { + if len(sha) > 8 { + return sha[:8] + } + return sha +} + func (c *Client) UnsetRemoteResolution(ctx context.Context, name string) error { args := []string{"config", "--unset", fmt.Sprintf("remote.%s.gh-resolved", name)} cmd, err := c.Command(ctx, args...) diff --git a/internal/skills/collisions.go b/internal/skills/discovery/collisions.go similarity index 89% rename from internal/skills/collisions.go rename to internal/skills/discovery/collisions.go index 87e4705c9..38bf9b26b 100644 --- a/internal/skills/collisions.go +++ b/internal/skills/discovery/collisions.go @@ -1,11 +1,9 @@ -package skills +package discovery import ( "fmt" "sort" "strings" - - "github.com/cli/cli/v2/internal/skills/discovery" ) // NameCollision represents a group of skills that share the same InstallName @@ -18,8 +16,8 @@ type NameCollision struct { // FindNameCollisions detects skills that share the same InstallName and returns a // sorted slice of collisions. Callers decide how to present the conflict to // the user (different flows need different error messages). -func FindNameCollisions(skills []discovery.Skill) []NameCollision { - byName := make(map[string][]discovery.Skill) +func FindNameCollisions(skills []Skill) []NameCollision { + byName := make(map[string][]Skill) for _, s := range skills { byName[s.InstallName()] = append(byName[s.InstallName()], s) } diff --git a/internal/skills/discovery/collisions_test.go b/internal/skills/discovery/collisions_test.go new file mode 100644 index 000000000..b499c497a --- /dev/null +++ b/internal/skills/discovery/collisions_test.go @@ -0,0 +1,62 @@ +package discovery + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFindNameCollisions(t *testing.T) { + tests := []struct { + name string + skills []Skill + want []NameCollision + }{ + { + name: "no collisions", + skills: []Skill{ + {Name: "code-review", Path: "skills/code-review"}, + {Name: "issue-triage", Path: "skills/issue-triage"}, + }, + want: nil, + }, + { + name: "single collision", + skills: []Skill{ + {Name: "pr-summary", Path: "skills/pr-summary"}, + {Name: "pr-summary", Path: "skills/monalisa/pr-summary"}, + }, + want: []NameCollision{ + {Name: "pr-summary", DisplayNames: []string{"pr-summary", "pr-summary"}}, + }, + }, + { + name: "collisions sorted by name", + skills: []Skill{ + {Name: "octocat-lint", Path: "skills/octocat-lint"}, + {Name: "octocat-lint", Path: "skills/hubot/octocat-lint"}, + {Name: "code-review", Path: "skills/code-review"}, + {Name: "code-review", Path: "skills/monalisa/code-review"}, + }, + want: []NameCollision{ + {Name: "code-review", DisplayNames: []string{"code-review", "code-review"}}, + {Name: "octocat-lint", DisplayNames: []string{"octocat-lint", "octocat-lint"}}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FindNameCollisions(tt.skills) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFormatCollisions(t *testing.T) { + collisions := []NameCollision{ + {Name: "pr-summary", DisplayNames: []string{"skills/pr-summary", "plugins/hubot/pr-summary"}}, + {Name: "code-review", DisplayNames: []string{"skills/code-review", "skills/monalisa/code-review"}}, + } + got := FormatCollisions(collisions) + assert.Equal(t, "pr-summary: skills/pr-summary, plugins/hubot/pr-summary\n code-review: skills/code-review, skills/monalisa/code-review", got) +} diff --git a/internal/skills/discovery/discovery.go b/internal/skills/discovery/discovery.go index fc234716a..05a531bc9 100644 --- a/internal/skills/discovery/discovery.go +++ b/internal/skills/discovery/discovery.go @@ -11,6 +11,7 @@ import ( "strings" "sync" + "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/skills/frontmatter" ) @@ -71,18 +72,6 @@ type ResolvedRef struct { SHA string // commit SHA } -// RESTClient is the interface for making GitHub REST API calls. -// It mirrors the subset of api.Client used by discovery. -type RESTClient interface { - // REST performs a REST API call. - // hostname is the GitHub host (e.g. "github.com"). - // method is the HTTP method (e.g. "GET"). - // path is the API path (e.g. "repos/owner/repo/releases/latest"). - // body is the request body (nil for GET). - // data is the response data to unmarshal into. - REST(hostname string, method string, path string, body io.Reader, data interface{}) error -} - type treeEntry struct { Path string `json:"path"` Mode string `json:"mode"` @@ -120,7 +109,7 @@ type repoResponse struct { // ResolveRef determines the git ref to use for a given owner/repo. // Priority: explicit version → latest release tag → default branch. -func ResolveRef(client RESTClient, host, owner, repo, version string) (*ResolvedRef, error) { +func ResolveRef(client *api.Client, host, owner, repo, version string) (*ResolvedRef, error) { if version != "" { return resolveExplicitRef(client, host, owner, repo, version) } @@ -134,7 +123,7 @@ func ResolveRef(client RESTClient, host, owner, repo, version string) (*Resolved // resolveExplicitRef resolves a user-supplied --pin value. It tries, in order: // tag → commit SHA. Branches are deliberately excluded because they are mutable // and pinning to one gives a false sense of reproducibility. -func resolveExplicitRef(client RESTClient, host, owner, repo, ref string) (*ResolvedRef, error) { +func resolveExplicitRef(client *api.Client, host, owner, repo, ref string) (*ResolvedRef, error) { tagPath := fmt.Sprintf("repos/%s/%s/git/ref/tags/%s", owner, repo, ref) var refResp struct { Object struct { @@ -170,7 +159,7 @@ func resolveExplicitRef(client RESTClient, host, owner, repo, ref string) (*Reso return nil, fmt.Errorf("ref %q not found as tag or commit in %s/%s", ref, owner, repo) } -func resolveLatestRelease(client RESTClient, host, owner, repo string) (*ResolvedRef, error) { +func resolveLatestRelease(client *api.Client, host, owner, repo string) (*ResolvedRef, error) { apiPath := fmt.Sprintf("repos/%s/%s/releases/latest", owner, repo) var release releaseResponse if err := client.REST(host, "GET", apiPath, nil, &release); err != nil { @@ -182,7 +171,7 @@ func resolveLatestRelease(client RESTClient, host, owner, repo string) (*Resolve return resolveExplicitRef(client, host, owner, repo, release.TagName) } -func resolveDefaultBranch(client RESTClient, host, owner, repo string) (*ResolvedRef, error) { +func resolveDefaultBranch(client *api.Client, host, owner, repo string) (*ResolvedRef, error) { apiPath := fmt.Sprintf("repos/%s/%s", owner, repo) var repoResp repoResponse if err := client.REST(host, "GET", apiPath, nil, &repoResp); err != nil { @@ -235,7 +224,7 @@ func matchSkillConventions(entry treeEntry) *skillMatch { parentDir := path.Dir(dir) skillName := path.Base(dir) - if !ValidateName(skillName) { + if !validateName(skillName) { return nil } @@ -246,7 +235,7 @@ func matchSkillConventions(entry treeEntry) *skillMatch { grandparentDir := path.Dir(parentDir) if grandparentDir == "skills" { namespace := path.Base(parentDir) - if !ValidateName(namespace) { + if !validateName(namespace) { return nil } return &skillMatch{entry: entry, name: skillName, namespace: namespace, skillDir: dir, convention: "skills-namespaced"} @@ -254,7 +243,7 @@ func matchSkillConventions(entry treeEntry) *skillMatch { if path.Base(parentDir) == "skills" && path.Dir(grandparentDir) == "plugins" { namespace := path.Base(grandparentDir) - if !ValidateName(namespace) { + if !validateName(namespace) { return nil } return &skillMatch{entry: entry, name: skillName, namespace: namespace, skillDir: dir, convention: "plugins"} @@ -268,7 +257,7 @@ func matchSkillConventions(entry treeEntry) *skillMatch { } // DiscoverSkills finds all skills in a repository at the given commit SHA. -func DiscoverSkills(client RESTClient, host, owner, repo, commitSHA string) ([]Skill, error) { +func DiscoverSkills(client *api.Client, host, owner, repo, commitSHA string) ([]Skill, error) { apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s?recursive=true", owner, repo, commitSHA) var tree treeResponse if err := client.REST(host, "GET", apiPath, nil, &tree); err != nil { @@ -332,8 +321,8 @@ func DiscoverSkills(client RESTClient, host, owner, repo, commitSHA string) ([]S return skills, nil } -// FetchDescription fetches and parses the frontmatter description for a skill. -func FetchDescription(client RESTClient, host, owner, repo string, skill *Skill) string { +// 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 "" } @@ -348,17 +337,8 @@ func FetchDescription(client RESTClient, host, owner, repo string, skill *Skill) return result.Metadata.Description } -// FetchDescriptions fetches descriptions for a batch of skills. -func FetchDescriptions(client RESTClient, host, owner, repo string, skills []Skill) { - for i := range skills { - if skills[i].Description == "" { - skills[i].Description = FetchDescription(client, host, owner, repo, &skills[i]) - } - } -} - // FetchDescriptionsConcurrent fetches descriptions with bounded concurrency. -func FetchDescriptionsConcurrent(client RESTClient, host, owner, repo string, skills []Skill, onProgress func(done, total int)) { +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 == "" { @@ -385,7 +365,7 @@ func FetchDescriptionsConcurrent(client RESTClient, host, owner, repo string, sk sem <- struct{}{} defer func() { <-sem }() - desc := FetchDescription(client, host, owner, repo, &skills[idx]) + desc := fetchDescription(client, host, owner, repo, &skills[idx]) mu.Lock() skills[idx].Description = desc @@ -401,12 +381,12 @@ func FetchDescriptionsConcurrent(client RESTClient, host, owner, repo string, sk } // DiscoverSkillByPath looks up a single skill by its exact path in the repository. -func DiscoverSkillByPath(client RESTClient, host, owner, repo, commitSHA, skillPath string) (*Skill, error) { +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) { + if !validateName(skillName) { return nil, fmt.Errorf("invalid skill name %q", skillName) } @@ -465,14 +445,14 @@ func DiscoverSkillByPath(client RESTClient, host, owner, repo, commitSHA, skillP TreeSHA: treeSHA, } - skill.Description = FetchDescription(client, host, owner, repo, skill) + 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 RESTClient, host, owner, repo, treeSHA, skillPath string) ([]treeEntry, error) { +func DiscoverSkillFiles(client *api.Client, host, owner, repo, treeSHA, skillPath string) ([]SkillFile, error) { apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s?recursive=true", owner, repo, treeSHA) var tree treeResponse if err := client.REST(host, "GET", apiPath, nil, &tree); err != nil { @@ -484,10 +464,10 @@ func DiscoverSkillFiles(client RESTClient, host, owner, repo, treeSHA, skillPath return walkTree(client, host, owner, repo, treeSHA, skillPath) } - var files []treeEntry + var files []SkillFile for _, entry := range tree.Tree { if entry.Type == "blob" { - files = append(files, treeEntry{ + files = append(files, SkillFile{ Path: skillPath + "/" + entry.Path, SHA: entry.SHA, Size: entry.Size, @@ -500,7 +480,7 @@ func DiscoverSkillFiles(client RESTClient, host, owner, repo, treeSHA, skillPath // ListSkillFiles returns all files in a skill directory as public SkillFile // structs with paths relative to the skill root. -func ListSkillFiles(client RESTClient, host, owner, repo, treeSHA string) ([]SkillFile, error) { +func ListSkillFiles(client *api.Client, host, owner, repo, treeSHA string) ([]SkillFile, error) { apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s?recursive=true", owner, repo, treeSHA) var tree treeResponse if err := client.REST(host, "GET", apiPath, nil, &tree); err != nil { @@ -509,17 +489,7 @@ func ListSkillFiles(client RESTClient, host, owner, repo, treeSHA string) ([]Ski if tree.Truncated { // Fall back to non-recursive traversal when the tree is too large. - entries, err := walkTree(client, host, owner, repo, treeSHA, "") - if err != nil { - return nil, err - } - var files []SkillFile - for _, e := range entries { - // walkTree prefixes with "/{path}", trim the leading slash. - p := strings.TrimPrefix(e.Path, "/") - files = append(files, SkillFile{Path: p, SHA: e.SHA, Size: e.Size}) - } - return files, nil + return walkTree(client, host, owner, repo, treeSHA, "") } var files []SkillFile @@ -537,19 +507,22 @@ func ListSkillFiles(client RESTClient, host, owner, repo, treeSHA string) ([]Ski // walkTree enumerates files by fetching each tree level individually, // avoiding the truncation limit of the recursive tree API. -func walkTree(client RESTClient, host, owner, repo, sha, prefix string) ([]treeEntry, error) { +func walkTree(client *api.Client, host, owner, repo, sha, prefix string) ([]SkillFile, error) { apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s", owner, repo, 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 []treeEntry + var files []SkillFile for _, entry := range tree.Tree { - entryPath := prefix + "/" + entry.Path + entryPath := entry.Path + if prefix != "" { + entryPath = prefix + "/" + entry.Path + } switch entry.Type { case "blob": - files = append(files, treeEntry{Path: entryPath, SHA: entry.SHA, Size: entry.Size}) + files = append(files, SkillFile{Path: entryPath, SHA: entry.SHA, Size: entry.Size}) case "tree": sub, err := walkTree(client, host, owner, repo, entry.SHA, entryPath) if err != nil { @@ -562,7 +535,7 @@ func walkTree(client RESTClient, host, owner, repo, sha, prefix string) ([]treeE } // FetchBlob retrieves the content of a blob by SHA. -func FetchBlob(client RESTClient, host, owner, repo, sha string) (string, error) { +func FetchBlob(client *api.Client, host, owner, repo, sha string) (string, error) { apiPath := fmt.Sprintf("repos/%s/%s/git/blobs/%s", owner, repo, sha) var blob blobResponse if err := client.REST(host, "GET", apiPath, nil, &blob); err != nil { @@ -683,7 +656,7 @@ func localSkillFromDir(dir string) (*Skill, error) { description = result.Metadata.Description } - if !ValidateName(name) { + if !validateName(name) { return nil, fmt.Errorf("invalid skill name %q in %s", name, dir) } @@ -694,8 +667,8 @@ func localSkillFromDir(dir string) (*Skill, error) { }, nil } -// ValidateName checks if a skill name is safe for use (filesystem-safe). -func ValidateName(name string) bool { +// 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 } @@ -715,59 +688,3 @@ func IsSpecCompliant(name string) bool { } return specNamePattern.MatchString(name) } - -// verifyBatchSize controls how many repos are checked per code-search API call. -const verifyBatchSize = 8 - -type codeSearchResponse struct { - Items []codeSearchItem `json:"items"` -} - -type codeSearchItem struct { - Repository codeSearchRepo `json:"repository"` -} - -type codeSearchRepo struct { - FullName string `json:"full_name"` -} - -// VerifySkillRepos filters a list of repository names to only those that -// actually contain SKILL.md files. It uses the GitHub code search API with -// batched repo: qualifiers. -// -// If a verification call fails (e.g. rate limit), repos in that batch are -// kept rather than silently dropped — we fail open. -func VerifySkillRepos(client RESTClient, host string, repos []string) map[string]bool { - verified := make(map[string]bool) - - for i := 0; i < len(repos); i += verifyBatchSize { - end := i + verifyBatchSize - if end > len(repos) { - end = len(repos) - } - batch := repos[i:end] - - var queryParts []string - queryParts = append(queryParts, "filename:SKILL.md") - for _, r := range batch { - queryParts = append(queryParts, "repo:"+r) - } - query := strings.Join(queryParts, "+") - apiPath := fmt.Sprintf("search/code?q=%s&per_page=%d", query, verifyBatchSize*3) - - var resp codeSearchResponse - if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { - // Fail open: if we can't verify, assume all repos in the batch are valid - for _, r := range batch { - verified[r] = true - } - continue - } - - for _, item := range resp.Items { - verified[item.Repository.FullName] = true - } - } - - return verified -} diff --git a/internal/skills/discovery/discovery_test.go b/internal/skills/discovery/discovery_test.go index b5fe2410d..5368ad23a 100644 --- a/internal/skills/discovery/discovery_test.go +++ b/internal/skills/discovery/discovery_test.go @@ -1,9 +1,13 @@ package discovery import ( + "net/http" "testing" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/pkg/httpmock" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestInstallName(t *testing.T) { @@ -14,18 +18,18 @@ func TestInstallName(t *testing.T) { }{ { name: "plain skill", - skill: Skill{Name: "git-commit"}, - wantName: "git-commit", + skill: Skill{Name: "code-review"}, + wantName: "code-review", }, { name: "namespaced skill", - skill: Skill{Name: "xlsx-pro", Namespace: "alice"}, - wantName: "alice/xlsx-pro", + skill: Skill{Name: "issue-triage", Namespace: "monalisa"}, + wantName: "monalisa/issue-triage", }, { name: "plugin skill with namespace", - skill: Skill{Name: "code-review", Namespace: "bob", Convention: "plugins"}, - wantName: "bob/code-review", + skill: Skill{Name: "pr-summary", Namespace: "hubot", Convention: "plugins"}, + wantName: "hubot/pr-summary", }, } for _, tt := range tests { @@ -35,48 +39,60 @@ func TestInstallName(t *testing.T) { } } -func TestMatchSkillConventions_PluginNamespace(t *testing.T) { - entry := treeEntry{ - Path: "plugins/bob/skills/code-review/SKILL.md", - Type: "blob", +func TestMatchSkillConventions(t *testing.T) { + tests := []struct { + name string + path string + wantNil bool + wantName string + wantNamespace string + wantConvention string + }{ + { + name: "plugin namespace", + path: "plugins/hubot/skills/pr-summary/SKILL.md", + wantName: "pr-summary", + wantNamespace: "hubot", + wantConvention: "plugins", + }, + { + name: "namespaced skill", + path: "skills/monalisa/issue-triage/SKILL.md", + wantName: "issue-triage", + wantNamespace: "monalisa", + wantConvention: "skills-namespaced", + }, + { + name: "regular skill", + path: "skills/code-review/SKILL.md", + wantName: "code-review", + wantConvention: "skills", + }, + { + name: "non-SKILL.md file", + path: "skills/code-review/README.md", + wantNil: true, + }, } - m := matchSkillConventions(entry) - assert.NotNil(t, m) - assert.Equal(t, "code-review", m.name) - assert.Equal(t, "bob", m.namespace) - assert.Equal(t, "plugins", m.convention) -} - -func TestMatchSkillConventions_NamespacedSkill(t *testing.T) { - entry := treeEntry{ - Path: "skills/alice/xlsx-pro/SKILL.md", - Type: "blob", + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := matchSkillConventions(treeEntry{Path: tt.path, Type: "blob"}) + if tt.wantNil { + assert.Nil(t, m) + return + } + require.NotNil(t, m) + assert.Equal(t, tt.wantName, m.name) + assert.Equal(t, tt.wantNamespace, m.namespace) + assert.Equal(t, tt.wantConvention, m.convention) + }) } - m := matchSkillConventions(entry) - assert.NotNil(t, m) - assert.Equal(t, "xlsx-pro", m.name) - assert.Equal(t, "alice", m.namespace) - assert.Equal(t, "skills-namespaced", m.convention) -} - -func TestMatchSkillConventions_RegularSkill(t *testing.T) { - entry := treeEntry{ - Path: "skills/git-commit/SKILL.md", - Type: "blob", - } - m := matchSkillConventions(entry) - assert.NotNil(t, m) - assert.Equal(t, "git-commit", m.name) - assert.Equal(t, "", m.namespace) - assert.Equal(t, "skills", m.convention) } func TestDuplicatePluginSkills_DifferentAuthors(t *testing.T) { - // Simulates a repo with the same skill name under two different plugin authors. - // Previously this caused a collision error; now each gets a distinct namespace. entries := []treeEntry{ - {Path: "plugins/author1/skills/azure-diag/SKILL.md", Type: "blob"}, - {Path: "plugins/author2/skills/azure-diag/SKILL.md", Type: "blob"}, + {Path: "plugins/monalisa/skills/code-review/SKILL.md", Type: "blob"}, + {Path: "plugins/hubot/skills/code-review/SKILL.md", Type: "blob"}, } seen := make(map[string]bool) @@ -90,20 +106,236 @@ func TestDuplicatePluginSkills_DifferentAuthors(t *testing.T) { matches = append(matches, *m) } - assert.Len(t, matches, 2) - assert.Equal(t, "author1", matches[0].namespace) - assert.Equal(t, "author2", matches[1].namespace) + require.Len(t, matches, 2) + assert.Equal(t, "monalisa", matches[0].namespace) + assert.Equal(t, "hubot", matches[1].namespace) + assert.NotEqual(t, + Skill{Name: matches[0].name, Namespace: matches[0].namespace}.InstallName(), + Skill{Name: matches[1].name, Namespace: matches[1].namespace}.InstallName(), + ) +} - // Build skills and verify they have different InstallNames - var skills []Skill - for _, m := range matches { - skills = append(skills, Skill{ - Name: m.name, - Namespace: m.namespace, - Convention: m.convention, +func TestValidateName(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + {name: "empty", input: "", want: false}, + {name: "too long", input: string(make([]byte, 65)), want: false}, + {name: "max length", input: "a" + string(make([]byte, 63)), want: false}, // 64 'a's would be valid but []byte gives null bytes + {name: "contains slash", input: "foo/bar", want: false}, + {name: "contains dotdot", input: "foo..bar", want: false}, + {name: "starts with dot", input: ".hidden", want: false}, + {name: "simple name", input: "code-review", want: true}, + {name: "with dots and underscores", input: "octocat_helper.v2", want: true}, + {name: "uppercase allowed", input: "Octocat", want: true}, + {name: "single char", input: "a", want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, validateName(tt.input)) + }) + } +} + +func TestIsSpecCompliant(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + {name: "empty", input: "", want: false}, + {name: "consecutive hyphens", input: "code--review", want: false}, + {name: "uppercase rejected", input: "Octocat", want: false}, + {name: "starts with hyphen", input: "-octocat", want: false}, + {name: "ends with hyphen", input: "octocat-", want: false}, + {name: "valid lowercase with hyphens", input: "issue-triage", want: true}, + {name: "valid single char", input: "a", want: true}, + {name: "valid with numbers", input: "copilot4", want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, IsSpecCompliant(tt.input)) + }) + } +} + +func TestResolveRef(t *testing.T) { + tests := []struct { + name string + version string + stubs func(*httpmock.Registry) + wantRef string + wantSHA string + wantErr string + }{ + { + name: "explicit version resolves lightweight tag", + version: "v1.0", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v1.0"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "abc123", "type": "commit"}, + })) + }, + wantRef: "v1.0", + wantSHA: "abc123", + }, + { + name: "explicit version resolves annotated tag", + version: "v2.0", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v2.0"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "tag-obj-sha", "type": "tag"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/tags/tag-obj-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "real-commit-sha"}, + })) + }, + wantRef: "v2.0", + wantSHA: "real-commit-sha", + }, + { + name: "explicit version falls back to commit SHA", + version: "deadbeef", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/deadbeef"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/commits/deadbeef"), + httpmock.JSONResponse(map[string]interface{}{"sha": "deadbeef"})) + }, + wantRef: "deadbeef", + wantSHA: "deadbeef", + }, + { + name: "explicit version not found anywhere", + version: "nonexistent", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/nonexistent"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/commits/nonexistent"), + httpmock.StatusStringResponse(404, "not found")) + }, + wantErr: `ref "nonexistent" not found as tag or commit in monalisa/octocat-skills`, + }, + { + name: "no version uses latest release", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.JSONResponse(map[string]interface{}{"tag_name": "v3.0"})) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "release-sha", "type": "commit"}, + })) + }, + wantRef: "v3.0", + wantSHA: "release-sha", + }, + { + name: "no version falls back to default branch when no releases", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"})) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/main"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "branch-sha"}, + })) + }, + wantRef: "main", + wantSHA: "branch-sha", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + ref, err := ResolveRef(client, "github.com", "monalisa", "octocat-skills", tt.version) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantRef, ref.Ref) + assert.Equal(t, tt.wantSHA, ref.SHA) + }) + } +} + +func TestFetchBlob(t *testing.T) { + tests := []struct { + name string + stubs func(*httpmock.Registry) + wantErr string + want string + }{ + { + name: "decodes base64 content", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/abc"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc", "encoding": "base64", "content": "SGVsbG8gV29ybGQ=", + })) + }, + want: "Hello World", + }, + { + name: "rejects non-base64 encoding", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/abc"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc", "encoding": "utf-8", "content": "raw", + })) + }, + wantErr: "unexpected blob encoding: utf-8", + }, + { + name: "API error", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/abc"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantErr: "could not fetch blob", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + got, err := FetchBlob(client, "github.com", "monalisa", "octocat-skills", "abc") + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) }) } - assert.Equal(t, "author1/azure-diag", skills[0].InstallName()) - assert.Equal(t, "author2/azure-diag", skills[1].InstallName()) - assert.NotEqual(t, skills[0].InstallName(), skills[1].InstallName()) } diff --git a/internal/skills/gitclient/gitclient.go b/internal/skills/gitclient/gitclient.go deleted file mode 100644 index 99735db90..000000000 --- a/internal/skills/gitclient/gitclient.go +++ /dev/null @@ -1,149 +0,0 @@ -// Package gitclient provides a shared adapter from the cli/cli git.Client -// (via cmdutil.Factory) to the narrow interfaces used by skills commands. -package gitclient - -import ( - "context" - "os" - "strings" - - "github.com/cli/cli/v2/pkg/cmdutil" -) - -// RootResolver can resolve the git repository root directory. -type RootResolver interface { - ToplevelDir() (string, error) -} - -// RemoteResolver can resolve git remote URLs. -type RemoteResolver interface { - RemoteURL(name string) (string, error) -} - -// Client is the full git operations interface used by skills commands. -type Client interface { - RootResolver - RemoteResolver - GitDir(dir string) error - Remotes() ([]string, error) - CurrentBranch(dir string) (string, error) - IsIgnored(dir, path string) bool -} - -// FactoryClient adapts the cli/cli git.Client to the Client interface. -type FactoryClient struct { - F *cmdutil.Factory -} - -// ToplevelDir returns the root directory of the current git repository. -func (g *FactoryClient) ToplevelDir() (string, error) { - cmd, err := g.F.GitClient.Command(context.Background(), "rev-parse", "--show-toplevel") - if err != nil { - return "", err - } - out, err := cmd.Output() - if err != nil { - return "", err - } - return strings.TrimSpace(string(out)), nil -} - -// RemoteURL returns the URL configured for the named git remote. -func (g *FactoryClient) RemoteURL(name string) (string, error) { - cmd, err := g.F.GitClient.Command(context.Background(), "remote", "get-url", name) - if err != nil { - return "", err - } - out, err := cmd.Output() - if err != nil { - return "", err - } - return strings.TrimSpace(string(out)), nil -} - -// GitDir validates that the given directory is inside a git repository. -func (g *FactoryClient) GitDir(dir string) error { - cmd, err := g.F.GitClient.Command(context.Background(), "-C", dir, "rev-parse", "--git-dir") - if err != nil { - return err - } - _, err = cmd.Output() - return err -} - -// Remotes returns the list of configured git remote names. -func (g *FactoryClient) Remotes() ([]string, error) { - cmd, err := g.F.GitClient.Command(context.Background(), "remote") - if err != nil { - return nil, err - } - out, err := cmd.Output() - if err != nil { - return nil, err - } - return strings.Fields(string(out)), nil -} - -// CurrentBranch returns the current branch name, or "" if HEAD is detached. -func (g *FactoryClient) CurrentBranch(dir string) (string, error) { - cmd, err := g.F.GitClient.Command(context.Background(), "-C", dir, "rev-parse", "--abbrev-ref", "HEAD") - if err != nil { - return "", err - } - out, err := cmd.Output() - if err != nil { - return "", err - } - branch := strings.TrimSpace(string(out)) - if branch == "HEAD" { - return "", nil // detached HEAD - } - return branch, nil -} - -// IsIgnored reports whether the given path is git-ignored in the given directory. -func (g *FactoryClient) IsIgnored(dir, path string) bool { - cmd, err := g.F.GitClient.Command(context.Background(), "-C", dir, "check-ignore", "-q", path) - if err != nil { - return false - } - _, err = cmd.Output() - return err == nil -} - -// ResolveGitRoot returns the git repository root using the provided resolver, -// falling back to the current working directory on error. -func ResolveGitRoot(resolver RootResolver) string { - if resolver == nil { - if cwd, err := os.Getwd(); err == nil { - return cwd - } - return "" - } - root, err := resolver.ToplevelDir() - if err != nil { - if cwd, cwdErr := os.Getwd(); cwdErr == nil { - return cwd - } - return "" - } - return root -} - -// ResolveHomeDir returns the user's home directory, or "" on error. -func ResolveHomeDir() string { - home, err := os.UserHomeDir() - if err != nil { - return "" - } - return home -} - -// TruncateSHA returns the first 8 characters of a SHA, or the full string -// if it is shorter. -func TruncateSHA(sha string) string { - if len(sha) > 8 { - return sha[:8] - } - return sha -} diff --git a/internal/skills/gitclient/gitclient_test.go b/internal/skills/gitclient/gitclient_test.go deleted file mode 100644 index 0b8a2cfff..000000000 --- a/internal/skills/gitclient/gitclient_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package gitclient - -import ( - "fmt" - "testing" - - "github.com/stretchr/testify/assert" -) - -type mockResolver struct { - root string - err error -} - -func (m *mockResolver) ToplevelDir() (string, error) { - if m.err != nil { - return "", m.err - } - return m.root, nil -} - -func TestResolveGitRoot(t *testing.T) { - t.Run("returns root on success", func(t *testing.T) { - got := ResolveGitRoot(&mockResolver{root: "/my/repo"}) - assert.Equal(t, "/my/repo", got) - }) - - t.Run("falls back to cwd on error", func(t *testing.T) { - got := ResolveGitRoot(&mockResolver{err: fmt.Errorf("not a git repo")}) - assert.NotEmpty(t, got) // falls back to cwd - }) - - t.Run("nil resolver falls back to cwd", func(t *testing.T) { - got := ResolveGitRoot(nil) - assert.NotEmpty(t, got) // falls back to cwd - }) -} - -func TestResolveHomeDir(t *testing.T) { - got := ResolveHomeDir() - assert.NotEmpty(t, got) -} - -func TestTruncateSHA(t *testing.T) { - assert.Equal(t, "abcdef12", TruncateSHA("abcdef1234567890")) - assert.Equal(t, "short", TruncateSHA("short")) - assert.Equal(t, "12345678", TruncateSHA("12345678")) - assert.Equal(t, "", TruncateSHA("")) -} diff --git a/internal/skills/hosts/hosts_test.go b/internal/skills/hosts/hosts_test.go deleted file mode 100644 index 78c2a3e9d..000000000 --- a/internal/skills/hosts/hosts_test.go +++ /dev/null @@ -1,113 +0,0 @@ -package hosts - -import ( - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestFindByID(t *testing.T) { - host, err := FindByID("github-copilot") - require.NoError(t, err) - assert.Equal(t, "GitHub Copilot", host.Name) - assert.Equal(t, ".github/skills", host.ProjectDir) -} - -func TestFindByID_Invalid(t *testing.T) { - _, err := FindByID("nonexistent") - assert.Error(t, err) - assert.Contains(t, err.Error(), "unknown host") -} - -func TestValidHostIDs(t *testing.T) { - ids := ValidHostIDs() - assert.Contains(t, ids, "github-copilot") - assert.Contains(t, ids, "claude-code") - assert.Contains(t, ids, "cursor") -} - -func TestHostNames(t *testing.T) { - names := HostNames() - assert.Contains(t, names, "GitHub Copilot") - assert.Contains(t, names, "Claude Code") -} - -func TestInstallDir_Project(t *testing.T) { - host, _ := FindByID("github-copilot") - dir, err := host.InstallDir(ScopeProject, "/tmp/myrepo", "/home/user") - require.NoError(t, err) - assert.Equal(t, filepath.Join("/tmp/myrepo", ".github", "skills"), dir) -} - -func TestInstallDir_User(t *testing.T) { - host, _ := FindByID("github-copilot") - dir, err := host.InstallDir(ScopeUser, "/tmp/myrepo", "/home/user") - require.NoError(t, err) - assert.Equal(t, filepath.Join("/home/user", ".copilot", "skills"), dir) -} - -func TestInstallDir_NoGitRoot(t *testing.T) { - host, _ := FindByID("github-copilot") - _, err := host.InstallDir(ScopeProject, "", "/home/user") - assert.Error(t, err) -} - -func TestRepoNameFromRemote(t *testing.T) { - tests := []struct { - remote string - want string - }{ - {"https://github.com/owner/repo.git", "owner/repo"}, - {"https://github.com/owner/repo", "owner/repo"}, - {"git@github.com:owner/repo.git", "owner/repo"}, - {"git@github.com:owner/repo", "owner/repo"}, - {"ssh://git@github.com/owner/repo.git", "owner/repo"}, - {"ssh://git@github.com/owner/repo", "owner/repo"}, - {"", ""}, - } - for _, tt := range tests { - t.Run(tt.remote, func(t *testing.T) { - assert.Equal(t, tt.want, RepoNameFromRemote(tt.remote)) - }) - } -} - -func TestUniqueProjectDirs(t *testing.T) { - dirs := UniqueProjectDirs() - - // Should contain all known project dirs - assert.Contains(t, dirs, ".github/skills") - assert.Contains(t, dirs, ".claude/skills") - assert.Contains(t, dirs, ".cursor/skills") - assert.Contains(t, dirs, ".agents/skills") - assert.Contains(t, dirs, ".agent/skills") - - // Should deduplicate — gemini and antigravity share .agent/skills - seen := map[string]int{} - for _, d := range dirs { - seen[d]++ - } - for dir, count := range seen { - assert.Equalf(t, 1, count, "directory %q appears %d times, expected 1", dir, count) - } -} - -func TestScopeLabels(t *testing.T) { - t.Run("without repo name", func(t *testing.T) { - labels := ScopeLabels("") - require.Len(t, labels, 2) - assert.Contains(t, labels[0], "Project") - assert.Contains(t, labels[0], "recommended") - assert.Contains(t, labels[1], "Global") - }) - - t.Run("with repo name", func(t *testing.T) { - labels := ScopeLabels("owner/repo") - require.Len(t, labels, 2) - assert.Contains(t, labels[0], "owner/repo") - assert.Contains(t, labels[0], "recommended") - assert.Contains(t, labels[1], "Global") - }) -} diff --git a/internal/skills/installer/installer.go b/internal/skills/installer/installer.go index 4c2a39256..ed2db5074 100644 --- a/internal/skills/installer/installer.go +++ b/internal/skills/installer/installer.go @@ -1,16 +1,19 @@ package installer import ( + "errors" "fmt" "os" "path/filepath" "strings" "sync" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/safepaths" "github.com/cli/cli/v2/internal/skills/discovery" "github.com/cli/cli/v2/internal/skills/frontmatter" - "github.com/cli/cli/v2/internal/skills/hosts" "github.com/cli/cli/v2/internal/skills/lockfile" + "github.com/cli/cli/v2/internal/skills/registry" ) // maxConcurrency limits parallel API requests to avoid rate limiting. @@ -25,12 +28,12 @@ type Options struct { SHA string // resolved commit SHA PinnedRef string // user-supplied --pin value (empty if unpinned) Skills []discovery.Skill - AgentHost *hosts.Host - Scope hosts.Scope + AgentHost *registry.AgentHost + Scope registry.Scope Dir string // explicit target directory (overrides AgentHost+Scope) GitRoot string // git repository root (for project scope) HomeDir string // user home directory (for user scope) - Client discovery.RESTClient + Client *api.Client OnProgress func(done, total int) // called after each skill is installed } @@ -138,8 +141,8 @@ func Install(opts *Options) (*Result, error) { type LocalOptions struct { SourceDir string Skills []discovery.Skill - AgentHost *hosts.Host - Scope hosts.Scope + AgentHost *registry.AgentHost + Scope registry.Scope Dir string GitRoot string HomeDir string @@ -182,7 +185,7 @@ func installLocalSkill(sourceRoot string, skill discovery.Skill, baseDir string) return fmt.Errorf("could not resolve source path: %w", err) } - absSkillDir, err := filepath.Abs(skillDir) + safeSkillDir, err := safepaths.ParseAbsolute(skillDir) if err != nil { return fmt.Errorf("could not resolve target path: %w", err) } @@ -203,20 +206,17 @@ func installLocalSkill(sourceRoot string, skill discovery.Skill, baseDir string) return err } - cleaned := filepath.Clean(relPath) - if cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) { - return nil - } - - destPath := filepath.Join(skillDir, cleaned) - - absDest, err := filepath.Abs(destPath) + // Defensive: filepath.WalkDir cannot produce traversal paths, but we + // guard against it in case the walk input is ever changed. + safeDest, err := safeSkillDir.Join(relPath) if err != nil { + var traversalErr safepaths.PathTraversalError + if errors.As(err, &traversalErr) { + return nil + } return fmt.Errorf("could not resolve destination path: %w", err) } - if !strings.HasPrefix(absDest, absSkillDir+string(filepath.Separator)) && absDest != absSkillDir { - return nil - } + destPath := safeDest.String() if dir := filepath.Dir(destPath); dir != skillDir { if err := os.MkdirAll(dir, 0o755); err != nil { @@ -252,7 +252,7 @@ func installSkill(opts *Options, skill discovery.Skill, baseDir string) error { return fmt.Errorf("could not list skill files: %w", err) } - absSkillDir, err := filepath.Abs(skillDir) + safeSkillDir, err := safepaths.ParseAbsolute(skillDir) if err != nil { return fmt.Errorf("could not resolve skill directory path: %w", err) } @@ -265,20 +265,15 @@ func installSkill(opts *Options, skill discovery.Skill, baseDir string) error { relPath := strings.TrimPrefix(file.Path, skill.Path+"/") - cleaned := filepath.Clean(relPath) - if cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) { - continue - } - - destPath := filepath.Join(skillDir, cleaned) - - absDest, err := filepath.Abs(destPath) + safeDest, err := safeSkillDir.Join(relPath) if err != nil { + var traversalErr safepaths.PathTraversalError + if errors.As(err, &traversalErr) { + continue + } return fmt.Errorf("could not resolve destination path: %w", err) } - if !strings.HasPrefix(absDest, absSkillDir+string(filepath.Separator)) && absDest != absSkillDir { - continue - } + destPath := safeDest.String() if dir := filepath.Dir(destPath); dir != skillDir { if err := os.MkdirAll(dir, 0o755); err != nil { diff --git a/internal/skills/installer/installer_test.go b/internal/skills/installer/installer_test.go new file mode 100644 index 000000000..2f6e09ca8 --- /dev/null +++ b/internal/skills/installer/installer_test.go @@ -0,0 +1,338 @@ +package installer + +import ( + "encoding/base64" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInstallLocalSkill(t *testing.T) { + tests := []struct { + name string + skill discovery.Skill + setup func(t *testing.T, srcDir string) + verify func(t *testing.T, destDir string) + }{ + { + name: "copies files", + skill: discovery.Skill{Name: "code-review", Path: "skills/code-review"}, + setup: func(t *testing.T, srcDir string) { + t.Helper() + skillSrc := filepath.Join(srcDir, "skills", "code-review") + require.NoError(t, os.MkdirAll(skillSrc, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("# Code Review"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(skillSrc, "prompt.txt"), []byte("review this PR"), 0o644)) + }, + verify: func(t *testing.T, destDir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(destDir, "code-review", "prompt.txt")) + require.NoError(t, err) + assert.Equal(t, "review this PR", string(content)) + + _, err = os.Stat(filepath.Join(destDir, "code-review", "SKILL.md")) + assert.NoError(t, err) + }, + }, + { + name: "nested directories", + skill: discovery.Skill{Name: "issue-triage", Path: "skills/issue-triage"}, + setup: func(t *testing.T, srcDir string) { + t.Helper() + deep := filepath.Join(srcDir, "skills", "issue-triage", "prompts", "templates") + require.NoError(t, os.MkdirAll(deep, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(deep, "bug.txt"), []byte("triage bug"), 0o644)) + require.NoError(t, os.WriteFile( + filepath.Join(srcDir, "skills", "issue-triage", "SKILL.md"), []byte("# Issue Triage"), 0o644)) + }, + verify: func(t *testing.T, destDir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(destDir, "issue-triage", "prompts", "templates", "bug.txt")) + require.NoError(t, err) + assert.Equal(t, "triage bug", string(content)) + }, + }, + { + name: "skips symlinks", + skill: discovery.Skill{Name: "pr-summary", Path: "skills/pr-summary"}, + setup: func(t *testing.T, srcDir string) { + t.Helper() + skillSrc := filepath.Join(srcDir, "skills", "pr-summary") + require.NoError(t, os.MkdirAll(skillSrc, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("# PR Summary"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(skillSrc, "prompt.txt"), []byte("summarize"), 0o644)) + os.Symlink(filepath.Join(skillSrc, "prompt.txt"), filepath.Join(skillSrc, "link.txt")) + }, + verify: func(t *testing.T, destDir string) { + t.Helper() + _, err := os.Stat(filepath.Join(destDir, "pr-summary", "prompt.txt")) + assert.NoError(t, err) + _, err = os.Stat(filepath.Join(destDir, "pr-summary", "link.txt")) + assert.True(t, os.IsNotExist(err)) + }, + }, + { + name: "injects metadata into SKILL.md", + skill: discovery.Skill{Name: "copilot-helper", Path: "skills/copilot-helper"}, + setup: func(t *testing.T, srcDir string) { + t.Helper() + skillSrc := filepath.Join(srcDir, "skills", "copilot-helper") + require.NoError(t, os.MkdirAll(skillSrc, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("# Copilot Helper\nAssists with tasks"), 0o644)) + }, + verify: func(t *testing.T, destDir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(destDir, "copilot-helper", "SKILL.md")) + require.NoError(t, err) + assert.True(t, strings.Contains(string(content), "local-path"), + "expected SKILL.md to contain local-path metadata, got: %s", string(content)) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srcDir := t.TempDir() + destDir := t.TempDir() + tt.setup(t, srcDir) + + err := installLocalSkill(srcDir, tt.skill, destDir) + require.NoError(t, err) + tt.verify(t, destDir) + }) + } +} + +func TestInstallSkill(t *testing.T) { + tests := []struct { + name string + skill discovery.Skill + stubs func(*httpmock.Registry) + verify func(t *testing.T, destDir string) + }{ + { + name: "installs files from remote", + skill: discovery.Skill{Name: "code-review", Path: "skills/code-review", TreeSHA: "tree123"}, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "skill-sha", "size": 10}, + {"path": "prompt.txt", "type": "blob", "sha": "prompt-sha", "size": 5}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/skill-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "skill-sha", "encoding": "base64", + "content": base64.StdEncoding.EncodeToString([]byte("# Code Review")), + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/prompt-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "prompt-sha", "encoding": "base64", + "content": base64.StdEncoding.EncodeToString([]byte("review this PR")), + })) + }, + verify: func(t *testing.T, destDir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(destDir, "code-review", "prompt.txt")) + require.NoError(t, err) + assert.Equal(t, "review this PR", string(content)) + + _, err = os.Stat(filepath.Join(destDir, "code-review", "SKILL.md")) + assert.NoError(t, err) + }, + }, + { + name: "injects metadata into SKILL.md", + skill: discovery.Skill{Name: "pr-summary", Path: "skills/pr-summary", TreeSHA: "tree456"}, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree456"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree456", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "md-sha", "size": 20}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/md-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "md-sha", "encoding": "base64", + "content": base64.StdEncoding.EncodeToString([]byte("# PR Summary\nSummarize pull requests")), + })) + }, + verify: func(t *testing.T, destDir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(destDir, "pr-summary", "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(content), "github-owner: monalisa") + assert.Contains(t, string(content), "github-repo: octocat-skills") + }, + }, + { + name: "skips path traversal from malicious tree", + skill: discovery.Skill{Name: "code-review", Path: "skills/code-review", TreeSHA: "tree123"}, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "safe-sha", "size": 10}, + {"path": "../../etc/passwd", "type": "blob", "sha": "evil-sha", "size": 100}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/safe-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "safe-sha", "encoding": "base64", + "content": base64.StdEncoding.EncodeToString([]byte("# Safe Skill")), + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/evil-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "evil-sha", "encoding": "base64", + "content": base64.StdEncoding.EncodeToString([]byte("malicious content")), + })) + }, + verify: func(t *testing.T, destDir string) { + t.Helper() + _, err := os.Stat(filepath.Join(destDir, "code-review", "SKILL.md")) + assert.NoError(t, err) + + _, err = os.Stat(filepath.Join(destDir, "..", "etc", "passwd")) + assert.True(t, os.IsNotExist(err), "traversal path should not be written") + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + destDir := t.TempDir() + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + opts := &Options{ + Host: "github.com", + Owner: "monalisa", + Repo: "octocat-skills", + Ref: "v1.0", + SHA: "commit123", + Client: client, + } + + err := installSkill(opts, tt.skill, destDir) + require.NoError(t, err) + tt.verify(t, destDir) + }) + } +} + +func stubTreeAndBlob(reg *httpmock.Registry, treeSHA string) { + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/monalisa/octocat-skills/git/trees/%s", treeSHA)), + httpmock.JSONResponse(map[string]interface{}{ + "sha": treeSHA, "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": treeSHA + "-blob", "size": 10}, + }, + })) + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/monalisa/octocat-skills/git/blobs/%s-blob", treeSHA)), + httpmock.JSONResponse(map[string]interface{}{ + "sha": treeSHA + "-blob", "encoding": "base64", + "content": base64.StdEncoding.EncodeToString([]byte("# Skill")), + })) +} + +func TestInstall(t *testing.T) { + tests := []struct { + name string + skills []discovery.Skill + stubs func(*httpmock.Registry) + wantInstalled []string + wantErr string + }{ + { + name: "single skill", + skills: []discovery.Skill{ + {Name: "code-review", Path: "skills/code-review", TreeSHA: "tree-cr"}, + }, + stubs: func(reg *httpmock.Registry) { stubTreeAndBlob(reg, "tree-cr") }, + wantInstalled: []string{"code-review"}, + }, + { + name: "multiple skills concurrently", + skills: []discovery.Skill{ + {Name: "code-review", Path: "skills/code-review", TreeSHA: "tree-cr"}, + {Name: "issue-triage", Path: "skills/issue-triage", TreeSHA: "tree-it"}, + }, + stubs: func(reg *httpmock.Registry) { + stubTreeAndBlob(reg, "tree-cr") + stubTreeAndBlob(reg, "tree-it") + }, + wantInstalled: []string{"code-review", "issue-triage"}, + }, + { + name: "no dir or agent host", + skills: []discovery.Skill{{Name: "code-review"}}, + stubs: func(reg *httpmock.Registry) {}, + wantErr: "either Dir or AgentHost must be specified", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + destDir := t.TempDir() + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + opts := &Options{ + Host: "github.com", + Owner: "monalisa", + Repo: "octocat-skills", + Ref: "v1.0", + SHA: "commit123", + Client: client, + Skills: tt.skills, + Dir: destDir, + } + if tt.wantErr != "" { + opts.Dir = "" + } + + result, err := Install(opts) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.ElementsMatch(t, tt.wantInstalled, result.Installed) + assert.Equal(t, destDir, result.Dir) + + homeDir, _ := os.UserHomeDir() + lockPath := filepath.Join(homeDir, ".agents", ".skill-lock.json") + lockData, err := os.ReadFile(lockPath) + require.NoError(t, err, "lockfile should have been written") + for _, name := range tt.wantInstalled { + assert.Contains(t, string(lockData), name) + } + }) + } +} diff --git a/internal/skills/lockfile/lockfile.go b/internal/skills/lockfile/lockfile.go index ad5fd4d4b..5761d24cf 100644 --- a/internal/skills/lockfile/lockfile.go +++ b/internal/skills/lockfile/lockfile.go @@ -15,8 +15,8 @@ const ( lockFile = ".skill-lock.json" ) -// Entry represents a single installed skill in the lock file. -type Entry struct { +// entry represents a single installed skill in the lock file. +type entry struct { Source string `json:"source"` SourceType string `json:"sourceType"` SourceURL string `json:"sourceUrl"` @@ -27,15 +27,15 @@ type Entry struct { PinnedRef string `json:"pinnedRef,omitempty"` } -// File is the top-level structure of .skill-lock.json. -type File struct { +// file is the top-level structure of .skill-lock.json. +type file struct { Version int `json:"version"` - Skills map[string]Entry `json:"skills"` + Skills map[string]entry `json:"skills"` Dismissed map[string]bool `json:"dismissed,omitempty"` } -// Path returns the absolute path to the lock file. -func Path() (string, error) { +// lockfilePath returns the absolute path to the lock file. +func lockfilePath() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", err @@ -43,10 +43,10 @@ func Path() (string, error) { return filepath.Join(home, agentsDir, lockFile), nil } -// Read loads the lock file, returning an empty file if it doesn't exist +// read loads the lock file, returning an empty file if it doesn't exist // or if it's an incompatible version. -func Read() (*File, error) { - lockPath, err := Path() +func read() (*file, error) { + lockPath, err := lockfilePath() if err != nil { return newFile(), nil //nolint:nilerr // graceful: no home dir means fresh state } @@ -59,7 +59,7 @@ func Read() (*File, error) { return nil, fmt.Errorf("could not read lock file: %w", err) } - var f File + var f file if err := json.Unmarshal(data, &f); err != nil { return newFile(), nil //nolint:nilerr // graceful: corrupt file means fresh state } @@ -71,9 +71,9 @@ func Read() (*File, error) { return &f, nil } -// Write persists the lock file to disk. -func Write(f *File) error { - lockPath, err := Path() +// write persists the lock file to disk. +func write(f *file) error { + lockPath, err := lockfilePath() if err != nil { return err } @@ -97,7 +97,7 @@ func RecordInstall(skillName, owner, repo, skillPath, treeSHA, pinnedRef string) unlock := acquireLock() defer unlock() - f, err := Read() + f, err := read() if err != nil { return err } @@ -110,7 +110,7 @@ func RecordInstall(skillName, owner, repo, skillPath, treeSHA, pinnedRef string) installedAt = existing.InstalledAt } - f.Skills[skillName] = Entry{ + f.Skills[skillName] = entry{ Source: owner + "/" + repo, SourceType: "github", SourceURL: "https://github.com/" + owner + "/" + repo + ".git", @@ -121,13 +121,13 @@ func RecordInstall(skillName, owner, repo, skillPath, treeSHA, pinnedRef string) PinnedRef: pinnedRef, } - return Write(f) + return write(f) } -func newFile() *File { - return &File{ +func newFile() *file { + return &file{ Version: lockVersion, - Skills: make(map[string]Entry), + Skills: make(map[string]entry), } } @@ -135,7 +135,7 @@ func newFile() *File { // Returns an unlock function. If locking fails after retries, it proceeds // unlocked rather than blocking the user indefinitely. func acquireLock() (unlock func()) { - lockPath, pathErr := Path() + lockPath, pathErr := lockfilePath() if pathErr != nil { return func() {} } diff --git a/internal/skills/lockfile/lockfile_test.go b/internal/skills/lockfile/lockfile_test.go new file mode 100644 index 000000000..b53d6aafc --- /dev/null +++ b/internal/skills/lockfile/lockfile_test.go @@ -0,0 +1,193 @@ +package lockfile + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// setupHome redirects HOME to a temp dir and returns the expected lockfile path. +func setupHome(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + return filepath.Join(home, agentsDir, lockFile) +} + +func TestRecordInstall(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) // optional pre-existing state + skill string + owner string + repo string + skillPath string + treeSHA string + pinnedRef string + verify func(t *testing.T, lockPath string) + }{ + { + name: "fresh install creates lockfile", + skill: "code-review", + owner: "monalisa", + repo: "octocat-skills", + skillPath: "skills/code-review/SKILL.md", + treeSHA: "abc123", + verify: func(t *testing.T, lockPath string) { + t.Helper() + f := readLockfile(t, lockPath) + require.Contains(t, f.Skills, "code-review") + e := f.Skills["code-review"] + assert.Equal(t, "monalisa/octocat-skills", e.Source) + assert.Equal(t, "github", e.SourceType) + assert.Equal(t, "https://github.com/monalisa/octocat-skills.git", e.SourceURL) + assert.Equal(t, "skills/code-review/SKILL.md", e.SkillPath) + assert.Equal(t, "abc123", e.SkillFolderHash) + assert.NotEmpty(t, e.InstalledAt) + assert.NotEmpty(t, e.UpdatedAt) + assert.Empty(t, e.PinnedRef) + }, + }, + { + name: "install with pinned ref", + skill: "pr-summary", + owner: "hubot", + repo: "skills-repo", + skillPath: "skills/pr-summary/SKILL.md", + treeSHA: "def456", + pinnedRef: "v1.0.0", + verify: func(t *testing.T, lockPath string) { + t.Helper() + f := readLockfile(t, lockPath) + assert.Equal(t, "v1.0.0", f.Skills["pr-summary"].PinnedRef) + }, + }, + { + name: "update preserves InstalledAt and updates treeSHA", + setup: func(t *testing.T) { + t.Helper() + require.NoError(t, RecordInstall("code-review", "monalisa", "octocat-skills", "skills/code-review/SKILL.md", "old-sha", "")) + }, + skill: "code-review", + owner: "monalisa", + repo: "octocat-skills", + skillPath: "skills/code-review/SKILL.md", + treeSHA: "new-sha", + verify: func(t *testing.T, lockPath string) { + t.Helper() + f := readLockfile(t, lockPath) + e := f.Skills["code-review"] + assert.Equal(t, "new-sha", e.SkillFolderHash, "treeSHA should be updated") + // InstalledAt should be preserved (not empty proves it wasn't clobbered) + assert.NotEmpty(t, e.InstalledAt, "InstalledAt should be preserved from first install") + }, + }, + { + name: "multiple skills coexist", + setup: func(t *testing.T) { + t.Helper() + require.NoError(t, RecordInstall("code-review", "monalisa", "octocat-skills", "skills/code-review/SKILL.md", "sha1", "")) + }, + skill: "issue-triage", + owner: "monalisa", + repo: "octocat-skills", + skillPath: "skills/issue-triage/SKILL.md", + treeSHA: "sha2", + verify: func(t *testing.T, lockPath string) { + t.Helper() + f := readLockfile(t, lockPath) + assert.Contains(t, f.Skills, "code-review") + assert.Contains(t, f.Skills, "issue-triage") + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lockPath := setupHome(t) + if tt.setup != nil { + tt.setup(t) + } + + err := RecordInstall(tt.skill, tt.owner, tt.repo, tt.skillPath, tt.treeSHA, tt.pinnedRef) + require.NoError(t, err) + tt.verify(t, lockPath) + }) + } +} + +func TestRead(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, lockPath string) + wantSkill bool + }{ + { + name: "missing file returns fresh state", + setup: func(t *testing.T, lockPath string) {}, + }, + { + name: "corrupt JSON returns fresh state", + setup: func(t *testing.T, lockPath string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755)) + require.NoError(t, os.WriteFile(lockPath, []byte("{invalid json"), 0o644)) + }, + }, + { + name: "wrong version returns fresh state", + setup: func(t *testing.T, lockPath string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755)) + data, _ := json.Marshal(file{Version: 999, Skills: map[string]entry{"x": {}}}) + require.NoError(t, os.WriteFile(lockPath, data, 0o644)) + }, + }, + { + name: "valid lockfile", + setup: func(t *testing.T, lockPath string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755)) + f := &file{ + Version: lockVersion, + Skills: map[string]entry{ + "code-review": {Source: "monalisa/octocat-skills", SourceType: "github"}, + }, + } + data, err := json.MarshalIndent(f, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(lockPath, data, 0o644)) + }, + wantSkill: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lockPath := setupHome(t) + tt.setup(t, lockPath) + + loaded, err := read() + require.NoError(t, err) + assert.Equal(t, lockVersion, loaded.Version) + + if tt.wantSkill { + assert.Contains(t, loaded.Skills, "code-review") + } else { + assert.Empty(t, loaded.Skills) + } + }) + } +} + +// readLockfile is a test helper that reads and parses the lockfile from disk. +func readLockfile(t *testing.T, path string) *file { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err, "lockfile should exist at %s", path) + var f file + require.NoError(t, json.Unmarshal(data, &f)) + return &f +} diff --git a/internal/skills/hosts/hosts.go b/internal/skills/registry/registry.go similarity index 72% rename from internal/skills/hosts/hosts.go rename to internal/skills/registry/registry.go index bee20b0f0..ecaaaa48d 100644 --- a/internal/skills/hosts/hosts.go +++ b/internal/skills/registry/registry.go @@ -1,16 +1,17 @@ -package hosts +package registry import ( "fmt" "path/filepath" + "strings" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/ghrepo" ) -// Host represents an AI agent that can use skills. -type Host struct { - // ID is the canonical identifier for this host. +// AgentHost represents an AI agent that can use skills. +type AgentHost struct { + // ID is the canonical identifier for this agent host. ID string // Name is the human-readable display name. Name string @@ -28,8 +29,8 @@ const ( ScopeUser Scope = "user" ) -// Registry contains all known agent hosts. -var Registry = []Host{ +// Agents contains all known agent hosts. +var Agents = []AgentHost{ { ID: "github-copilot", Name: "GitHub Copilot", @@ -68,52 +69,45 @@ var Registry = []Host{ }, } -// FindByID returns the host with the given ID, or an error if not found. -func FindByID(id string) (*Host, error) { - for i := range Registry { - if Registry[i].ID == id { - return &Registry[i], nil +// FindByID returns the agent host with the given ID, or an error if not found. +func FindByID(id string) (*AgentHost, error) { + for i := range Agents { + if Agents[i].ID == id { + return &Agents[i], nil } } - return nil, fmt.Errorf("unknown host %q, valid hosts: %s", id, ValidHostIDs()) + return nil, fmt.Errorf("unknown agent %q, valid agents: %s", id, ValidAgentIDs()) } -// ValidHostIDs returns a comma-separated list of valid host IDs. -func ValidHostIDs() string { - ids := "" - for i, h := range Registry { - if i > 0 { - ids += ", " - } - ids += h.ID - } - return ids +// ValidAgentIDs returns a comma-separated list of valid agent IDs. +func ValidAgentIDs() string { + return strings.Join(AgentIDs(), ", ") } -// HostIDs returns the IDs of all known hosts as a slice. -func HostIDs() []string { - ids := make([]string, len(Registry)) - for i, h := range Registry { +// AgentIDs returns the IDs of all known agents as a slice. +func AgentIDs() []string { + ids := make([]string, len(Agents)) + for i, h := range Agents { ids[i] = h.ID } return ids } -// HostNames returns the display names of all hosts for prompting. -func HostNames() []string { - names := make([]string, len(Registry)) - for i, h := range Registry { +// AgentNames returns the display names of all agents for prompting. +func AgentNames() []string { + names := make([]string, len(Agents)) + for i, h := range Agents { names[i] = h.Name } return names } // UniqueProjectDirs returns the deduplicated set of project-scope skill -// directories from the Registry, preserving insertion order. +// directories from the Agents list, preserving insertion order. func UniqueProjectDirs() []string { seen := map[string]bool{} var dirs []string - for _, h := range Registry { + for _, h := range Agents { if !seen[h.ProjectDir] { seen[h.ProjectDir] = true dirs = append(dirs, h.ProjectDir) @@ -122,12 +116,12 @@ func UniqueProjectDirs() []string { return dirs } -// InstallDir resolves the absolute installation directory for a host and scope. +// InstallDir resolves the absolute installation directory for an agent host and scope. // For project scope, it uses the provided git root directory so that skills are // installed at the top level regardless of which subdirectory the user is in. // Returns an error when gitRoot is empty (not in a git repository). // For user scope, it uses the home directory. -func (h *Host) InstallDir(scope Scope, gitRoot, homeDir string) (string, error) { +func (h *AgentHost) InstallDir(scope Scope, gitRoot, homeDir string) (string, error) { switch scope { case ScopeProject: if gitRoot == "" { diff --git a/internal/skills/registry/registry_test.go b/internal/skills/registry/registry_test.go new file mode 100644 index 000000000..f37c35e96 --- /dev/null +++ b/internal/skills/registry/registry_test.go @@ -0,0 +1,153 @@ +package registry + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFindByID(t *testing.T) { + tests := []struct { + name string + id string + wantName string + wantErr string + }{ + {name: "github-copilot", id: "github-copilot", wantName: "GitHub Copilot"}, + {name: "claude-code", id: "claude-code", wantName: "Claude Code"}, + {name: "cursor", id: "cursor", wantName: "Cursor"}, + {name: "codex", id: "codex", wantName: "Codex"}, + {name: "gemini", id: "gemini", wantName: "Gemini CLI"}, + {name: "antigravity", id: "antigravity", wantName: "Antigravity"}, + {name: "unknown agent", id: "nonexistent", wantErr: "unknown agent"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + host, err := FindByID(tt.id) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantName, host.Name) + }) + } +} + +func TestInstallDir(t *testing.T) { + host, err := FindByID("github-copilot") + require.NoError(t, err) + + tests := []struct { + name string + scope Scope + gitRoot string + homeDir string + wantDir string + wantErr bool + }{ + { + name: "project scope", + scope: ScopeProject, + gitRoot: "/tmp/monalisa-repo", + homeDir: "/home/monalisa", + wantDir: filepath.Join("/tmp/monalisa-repo", ".github", "skills"), + }, + { + name: "user scope", + scope: ScopeUser, + gitRoot: "/tmp/monalisa-repo", + homeDir: "/home/monalisa", + wantDir: filepath.Join("/home/monalisa", ".copilot", "skills"), + }, + { + name: "project scope without git root", + scope: ScopeProject, + gitRoot: "", + homeDir: "/home/monalisa", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir, err := host.InstallDir(tt.scope, tt.gitRoot, tt.homeDir) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantDir, dir) + }) + } +} + +func TestRepoNameFromRemote(t *testing.T) { + tests := []struct { + remote string + want string + }{ + {"https://github.com/monalisa/octocat-skills.git", "monalisa/octocat-skills"}, + {"https://github.com/monalisa/octocat-skills", "monalisa/octocat-skills"}, + {"git@github.com:monalisa/octocat-skills.git", "monalisa/octocat-skills"}, + {"git@github.com:monalisa/octocat-skills", "monalisa/octocat-skills"}, + {"ssh://git@github.com/monalisa/octocat-skills.git", "monalisa/octocat-skills"}, + {"ssh://git@github.com/monalisa/octocat-skills", "monalisa/octocat-skills"}, + {"", ""}, + } + for _, tt := range tests { + t.Run(tt.remote, func(t *testing.T) { + assert.Equal(t, tt.want, RepoNameFromRemote(tt.remote)) + }) + } +} + +func TestUniqueProjectDirs(t *testing.T) { + dirs := UniqueProjectDirs() + require.NotEmpty(t, dirs) + + // Should deduplicate — e.g. gemini and antigravity share .agent/skills + seen := map[string]int{} + for _, d := range dirs { + seen[d]++ + } + for dir, count := range seen { + assert.Equalf(t, 1, count, "directory %q appears %d times, expected 1", dir, count) + } +} + +func TestScopeLabels(t *testing.T) { + tests := []struct { + name string + repoName string + wantFirst []string + wantSecond []string + }{ + { + name: "without repo name", + repoName: "", + wantFirst: []string{"Project", "recommended"}, + wantSecond: []string{"Global"}, + }, + { + name: "with repo name", + repoName: "monalisa/octocat-skills", + wantFirst: []string{"monalisa/octocat-skills", "recommended"}, + wantSecond: []string{"Global"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + labels := ScopeLabels(tt.repoName) + require.Len(t, labels, 2) + for _, s := range tt.wantFirst { + assert.Contains(t, labels[0], s) + } + for _, s := range tt.wantSecond { + assert.Contains(t, labels[1], s) + } + }) + } +} diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index 38613800d..eac2e4a00 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -1,6 +1,7 @@ package install import ( + "context" "errors" "fmt" "io" @@ -12,14 +13,14 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" + ghContext "github.com/cli/cli/v2/context" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" - "github.com/cli/cli/v2/internal/skills" "github.com/cli/cli/v2/internal/skills/discovery" "github.com/cli/cli/v2/internal/skills/frontmatter" - "github.com/cli/cli/v2/internal/skills/gitclient" - "github.com/cli/cli/v2/internal/skills/hosts" "github.com/cli/cli/v2/internal/skills/installer" + "github.com/cli/cli/v2/internal/skills/registry" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -40,7 +41,8 @@ type installOptions struct { IO *iostreams.IOStreams HttpClient func() (*http.Client, error) Prompter prompter.Prompter - GitClient installGitClient + GitClient *git.Client + Remotes func() (ghContext.Remotes, error) // Arguments SkillSource string // owner/repo or local path @@ -61,18 +63,13 @@ type installOptions struct { version string } -// installGitClient is the git interface needed by the install command. -type installGitClient interface { - gitclient.RootResolver - gitclient.RemoteResolver -} - // NewCmdInstall creates the "skills install" command. func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra.Command { opts := &installOptions{ IO: f.IOStreams, Prompter: f.Prompter, - GitClient: &gitclient.FactoryClient{F: f}, + GitClient: f.GitClient, + Remotes: f.Remotes, HttpClient: f.HttpClient, } @@ -188,7 +185,7 @@ func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra. } if opts.Agent != "" { - if _, err := hosts.FindByID(opts.Agent); err != nil { + if _, err := registry.FindByID(opts.Agent); err != nil { return cmdutil.FlagErrorf("invalid value for --agent: %s", err) } } @@ -204,9 +201,9 @@ func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra. }, } - cmd.Flags().StringVar(&opts.Agent, "agent", "", fmt.Sprintf("target agent (%s)", hosts.ValidHostIDs())) + cmd.Flags().StringVar(&opts.Agent, "agent", "", fmt.Sprintf("target agent (%s)", registry.ValidAgentIDs())) _ = cmd.RegisterFlagCompletionFunc("agent", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { - return hosts.HostIDs(), cobra.ShellCompDirectiveNoFileComp + return registry.AgentIDs(), cobra.ShellCompDirectiveNoFileComp }) cmdutil.StringEnumFlag(cmd, &opts.Scope, "scope", "", "project", []string{"project", "user"}, "Installation scope") cmd.Flags().StringVar(&opts.Pin, "pin", "", "pin to a specific git tag or commit SHA") @@ -287,12 +284,12 @@ func installRun(opts *installOptions) error { return err } - gitRoot := gitclient.ResolveGitRoot(opts.GitClient) - homeDir := gitclient.ResolveHomeDir() + gitRoot := resolveGitRoot(opts.GitClient) + homeDir := resolveHomeDir() source = ghrepo.FullName(opts.repo) type hostPlan struct { - host *hosts.Host + host *registry.AgentHost skills []discovery.Skill } var plans []hostPlan @@ -426,11 +423,11 @@ func runLocalInstall(opts *installOptions) error { return err } - gitRoot := gitclient.ResolveGitRoot(opts.GitClient) - homeDir := gitclient.ResolveHomeDir() + gitRoot := resolveGitRoot(opts.GitClient) + homeDir := resolveHomeDir() type hostPlan struct { - host *hosts.Host + host *registry.AgentHost skills []discovery.Skill } var plans []hostPlan @@ -534,18 +531,18 @@ func cutLast(s, sep string) (before, after string, found bool) { return s, "", false } -func resolveVersion(opts *installOptions, client discovery.RESTClient, hostname string) (*discovery.ResolvedRef, error) { +func resolveVersion(opts *installOptions, client *api.Client, hostname string) (*discovery.ResolvedRef, error) { opts.IO.StartProgressIndicatorWithLabel("Resolving version") resolved, err := discovery.ResolveRef(client, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), opts.version) opts.IO.StopProgressIndicator() if err != nil { return nil, fmt.Errorf("could not resolve version: %w", err) } - fmt.Fprintf(opts.IO.ErrOut, "Using ref %s (%s)\n", resolved.Ref, gitclient.TruncateSHA(resolved.SHA)) + fmt.Fprintf(opts.IO.ErrOut, "Using ref %s (%s)\n", resolved.Ref, git.ShortSHA(resolved.SHA)) return resolved, nil } -func discoverSkills(opts *installOptions, client discovery.RESTClient, hostname string, resolved *discovery.ResolvedRef) ([]discovery.Skill, error) { +func discoverSkills(opts *installOptions, client *api.Client, hostname string, resolved *discovery.ResolvedRef) ([]discovery.Skill, error) { opts.IO.StartProgressIndicatorWithLabel("Discovering skills") skills, err := discovery.DiscoverSkills(client, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), resolved.SHA) opts.IO.StopProgressIndicator() @@ -755,7 +752,7 @@ func matchSelectedSkills(skills []discovery.Skill, selected []string) ([]discove // collisionError checks for name collisions and returns an error with // guidance on how to install skills individually. func collisionError(ss []discovery.Skill, sourceHint string) error { - collisions := skills.FindNameCollisions(ss) + collisions := discovery.FindNameCollisions(ss) if len(collisions) == 0 { return nil } @@ -764,28 +761,28 @@ func collisionError(ss []discovery.Skill, sourceHint string) error { %s Install these skills individually using the full name: gh skills install %s namespace/skill-name - `, skills.FormatCollisions(collisions), sourceHint)) + `, discovery.FormatCollisions(collisions), sourceHint)) } -func resolveHosts(opts *installOptions, canPrompt bool) ([]*hosts.Host, error) { +func resolveHosts(opts *installOptions, canPrompt bool) ([]*registry.AgentHost, error) { if opts.Agent != "" { - h, err := hosts.FindByID(opts.Agent) + h, err := registry.FindByID(opts.Agent) if err != nil { return nil, err } - return []*hosts.Host{h}, nil + return []*registry.AgentHost{h}, nil } if !canPrompt { - h, err := hosts.FindByID("github-copilot") + h, err := registry.FindByID("github-copilot") if err != nil { return nil, err } - return []*hosts.Host{h}, nil + return []*registry.AgentHost{h}, nil } fmt.Fprintln(opts.IO.ErrOut) - names := hosts.HostNames() + names := registry.AgentNames() indices, err := opts.Prompter.MultiSelect("Select target agent(s):", []string{names[0]}, names) if err != nil { return nil, err @@ -795,41 +792,43 @@ func resolveHosts(opts *installOptions, canPrompt bool) ([]*hosts.Host, error) { return nil, fmt.Errorf("must select at least one target agent") } - selected := make([]*hosts.Host, len(indices)) + selected := make([]*registry.AgentHost, len(indices)) for i, idx := range indices { - selected[i] = &hosts.Registry[idx] + selected[i] = ®istry.Agents[idx] } return selected, nil } -func resolveScope(opts *installOptions, canPrompt bool) (hosts.Scope, error) { +func resolveScope(opts *installOptions, canPrompt bool) (registry.Scope, error) { if opts.Dir != "" { - return hosts.Scope(opts.Scope), nil + return registry.Scope(opts.Scope), nil } if opts.ScopeChanged || !canPrompt { - return hosts.Scope(opts.Scope), nil + return registry.Scope(opts.Scope), nil } var repoName string - if remote, err := opts.GitClient.RemoteURL("origin"); err == nil { - repoName = hosts.RepoNameFromRemote(remote) + if opts.Remotes != nil { + if remotes, err := opts.Remotes(); err == nil && len(remotes) > 0 { + repoName = ghrepo.FullName(remotes[0].Repo) + } } - idx, err := opts.Prompter.Select("Installation scope:", "", hosts.ScopeLabels(repoName)) + idx, err := opts.Prompter.Select("Installation scope:", "", registry.ScopeLabels(repoName)) if err != nil { return "", err } if idx == 0 { - return hosts.ScopeProject, nil + return registry.ScopeProject, nil } - return hosts.ScopeUser, nil + return registry.ScopeUser, nil } func truncateDescription(s string, maxWidth int) string { return text.Truncate(maxWidth, text.RemoveExcessiveWhitespace(s)) } -func checkOverwrite(opts *installOptions, skills []discovery.Skill, host *hosts.Host, scope hosts.Scope, gitRoot, homeDir string, canPrompt bool) ([]discovery.Skill, error) { +func checkOverwrite(opts *installOptions, skills []discovery.Skill, host *registry.AgentHost, scope registry.Scope, gitRoot, homeDir string, canPrompt bool) ([]discovery.Skill, error) { targetDir := opts.Dir if targetDir == "" { var err error @@ -991,3 +990,28 @@ func printReviewHint(w io.Writer, cs *iostreams.ColorScheme, repo string, skillN } fmt.Fprintln(w) } + +func resolveGitRoot(gc *git.Client) string { + if gc == nil { + if cwd, err := os.Getwd(); err == nil { + return cwd + } + return "" + } + root, err := gc.ToplevelDir(context.Background()) + if err != nil { + if cwd, err := os.Getwd(); err == nil { + return cwd + } + return "" + } + return root +} + +func resolveHomeDir() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return home +} diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index f53fd2267..658815b63 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -13,8 +13,7 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/skills/discovery" - "github.com/cli/cli/v2/internal/skills/gitclient" - "github.com/cli/cli/v2/internal/skills/hosts" + "github.com/cli/cli/v2/internal/skills/registry" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" @@ -23,27 +22,6 @@ import ( "github.com/stretchr/testify/require" ) -// mockGitClient implements installGitClient for testing. -type mockGitClient struct { - root string - remote string - err error -} - -func (m *mockGitClient) ToplevelDir() (string, error) { - if m.err != nil { - return "", m.err - } - return m.root, nil -} - -func (m *mockGitClient) RemoteURL(_ string) (string, error) { - if m.err != nil { - return "", m.err - } - return m.remote, nil -} - func TestNewCmdInstall_Help(t *testing.T) { ios, _, _, _ := iostreams.Test() f := &cmdutil.Factory{ @@ -184,7 +162,7 @@ func TestInstallRun_NonInteractive_NoRepo(t *testing.T) { opts := &installOptions{ IO: ios, - GitClient: &mockGitClient{root: "/tmp", remote: ""}, + GitClient: &git.Client{RepoDir: t.TempDir()}, } err := installRun(opts) @@ -368,11 +346,6 @@ func TestResolveHosts_NoneSelected(t *testing.T) { assert.Error(t, err) } -func TestTruncateSHA(t *testing.T) { - assert.Equal(t, "abc123de", gitclient.TruncateSHA("abc123def456")) - assert.Equal(t, "short", gitclient.TruncateSHA("short")) -} - func TestTruncateDescription(t *testing.T) { tests := []struct { name string @@ -457,7 +430,7 @@ func TestRunLocalInstall_NonInteractive(t *testing.T) { Agent: "github-copilot", Scope: "project", Dir: targetDir, - GitClient: &mockGitClient{root: t.TempDir(), remote: ""}, + GitClient: &git.Client{RepoDir: t.TempDir()}, } err := installRun(opts) @@ -489,7 +462,7 @@ func TestRunLocalInstall_SingleSkillDir(t *testing.T) { Agent: "github-copilot", Scope: "project", Dir: targetDir, - GitClient: &mockGitClient{root: t.TempDir(), remote: ""}, + GitClient: &git.Client{RepoDir: t.TempDir()}, } err := installRun(opts) @@ -577,7 +550,7 @@ func TestResolveScope_ExplicitFlag(t *testing.T) { IO: ios, Scope: "user", ScopeChanged: true, - GitClient: &mockGitClient{root: "/tmp", remote: ""}, + GitClient: &git.Client{RepoDir: t.TempDir()}, } scope, err := resolveScope(opts, true) require.NoError(t, err) @@ -590,7 +563,7 @@ func TestResolveScope_DirBypasses(t *testing.T) { IO: ios, Dir: "/tmp/custom", Scope: "project", - GitClient: &mockGitClient{root: "/tmp", remote: ""}, + GitClient: &git.Client{RepoDir: t.TempDir()}, } scope, err := resolveScope(opts, true) require.NoError(t, err) @@ -601,10 +574,10 @@ func TestCheckOverwrite_NoExisting(t *testing.T) { ios, _, _, _ := iostreams.Test() targetDir := t.TempDir() skills := []discovery.Skill{{Name: "new-skill"}} - host := &hosts.Host{ID: "test", ProjectDir: "skills"} + host := ®istry.AgentHost{ID: "test", ProjectDir: "skills"} opts := &installOptions{IO: ios, Dir: targetDir} - got, err := checkOverwrite(opts, skills, host, hosts.ScopeProject, "/tmp", "/home", false) + got, err := checkOverwrite(opts, skills, host, registry.ScopeProject, "/tmp", "/home", false) require.NoError(t, err) assert.Len(t, got, 1) } @@ -615,10 +588,10 @@ func TestCheckOverwrite_ExistingWithForce(t *testing.T) { ios, _, _, _ := iostreams.Test() skills := []discovery.Skill{{Name: "existing-skill"}} - host := &hosts.Host{ID: "test", ProjectDir: "skills"} + host := ®istry.AgentHost{ID: "test", ProjectDir: "skills"} opts := &installOptions{IO: ios, Dir: targetDir, Force: true} - got, err := checkOverwrite(opts, skills, host, hosts.ScopeProject, "/tmp", "/home", false) + got, err := checkOverwrite(opts, skills, host, registry.ScopeProject, "/tmp", "/home", false) require.NoError(t, err) assert.Len(t, got, 1) } @@ -629,10 +602,10 @@ func TestCheckOverwrite_ExistingNonInteractive(t *testing.T) { ios, _, _, _ := iostreams.Test() skills := []discovery.Skill{{Name: "existing-skill"}} - host := &hosts.Host{ID: "test", ProjectDir: "skills"} + host := ®istry.AgentHost{ID: "test", ProjectDir: "skills"} opts := &installOptions{IO: ios, Dir: targetDir} - _, err := checkOverwrite(opts, skills, host, hosts.ScopeProject, "/tmp", "/home", false) + _, err := checkOverwrite(opts, skills, host, registry.ScopeProject, "/tmp", "/home", false) assert.Error(t, err) assert.Contains(t, err.Error(), "already installed") } @@ -755,7 +728,7 @@ func TestInstallRun_RemoteInstall(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - GitClient: &mockGitClient{root: t.TempDir(), remote: ""}, + GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "owner/repo", SkillName: "test-skill", Agent: "github-copilot", @@ -880,7 +853,7 @@ func TestRunLocalInstall_NamespacedSkills(t *testing.T) { Agent: "github-copilot", Scope: "project", Dir: targetDir, - GitClient: &mockGitClient{root: t.TempDir(), remote: ""}, + GitClient: &git.Client{RepoDir: t.TempDir()}, } err := installRun(opts) @@ -908,10 +881,10 @@ func TestCheckOverwrite_NamespacedSkill(t *testing.T) { {Name: "xlsx-pro", Namespace: "alice"}, {Name: "xlsx-pro", Namespace: "bob"}, } - host := &hosts.Host{ID: "test", ProjectDir: "skills"} + host := ®istry.AgentHost{ID: "test", ProjectDir: "skills"} opts := &installOptions{IO: ios, Dir: targetDir, Force: true} - got, err := checkOverwrite(opts, skills, host, hosts.ScopeProject, "/tmp", "/home", false) + got, err := checkOverwrite(opts, skills, host, registry.ScopeProject, "/tmp", "/home", false) require.NoError(t, err) assert.Len(t, got, 2, "both skills should be installable (force mode)") } diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go new file mode 100644 index 000000000..9541f4230 --- /dev/null +++ b/pkg/cmd/skills/preview/preview.go @@ -0,0 +1,382 @@ +package preview + +import ( + "fmt" + "io" + "net/http" + "sort" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/internal/skills/frontmatter" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/pkg/markdown" + "github.com/spf13/cobra" +) + +type previewOptions struct { + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + Prompter prompter.Prompter + Executable func() string + + RepoArg string + SkillName string + + repo ghrepo.Interface +} + +// NewCmdPreview creates the "skills preview" command. +func NewCmdPreview(f *cmdutil.Factory, runF func(*previewOptions) error) *cobra.Command { + opts := &previewOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Prompter: f.Prompter, + Executable: f.Executable, + } + + cmd := &cobra.Command{ + Use: "preview []", + Short: "Preview a skill from a GitHub repository", + Long: heredoc.Doc(` + Render a skill's SKILL.md content in the terminal. This fetches the + skill file from the repository and displays it using the configured + pager, without installing anything. + + A file tree is shown first, followed by the rendered SKILL.md content. + When running interactively and the skill contains additional files + (scripts, references, etc.), a file picker lets you browse them + individually. + + When run with only a repository argument, lists available skills and + prompts for selection. + `), + Example: heredoc.Doc(` + # Preview a specific skill + $ gh skills preview github/awesome-copilot code-review + + # Browse and preview interactively + $ gh skills preview github/awesome-copilot + `), + Aliases: []string{"show"}, + Args: cobra.RangeArgs(1, 2), + RunE: func(c *cobra.Command, args []string) error { + opts.RepoArg = args[0] + if len(args) == 2 { + opts.SkillName = args[1] + } + + repo, err := ghrepo.FromFullName(opts.RepoArg) + if err != nil { + return err + } + opts.repo = repo + + if runF != nil { + return runF(opts) + } + return previewRun(opts) + }, + } + + return cmd +} + +func previewRun(opts *previewOptions) error { + cs := opts.IO.ColorScheme() + + repo := opts.repo + owner := repo.RepoOwner() + repoName := repo.RepoName() + hostname := repo.RepoHost() + + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Resolving %s/%s", owner, repoName)) + resolved, err := discovery.ResolveRef(apiClient, hostname, owner, repoName, "") + opts.IO.StopProgressIndicator() + if err != nil { + return fmt.Errorf("could not resolve version: %w", err) + } + + opts.IO.StartProgressIndicatorWithLabel("Discovering skills") + skills, err := discovery.DiscoverSkills(apiClient, hostname, owner, repoName, resolved.SHA) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + sort.Slice(skills, func(i, j int) bool { + return skills[i].DisplayName() < skills[j].DisplayName() + }) + + skill, err := selectSkill(opts, skills) + if err != nil { + return err + } + + opts.IO.StartProgressIndicatorWithLabel("Fetching skill content") + var files []discovery.SkillFile + if skill.TreeSHA != "" { + files, err = discovery.ListSkillFiles(apiClient, hostname, owner, repoName, skill.TreeSHA) + if err != nil { + fmt.Fprintf(opts.IO.ErrOut, "warning: could not list skill files: %v\n", err) + files = nil + } + } + content, err := discovery.FetchBlob(apiClient, hostname, owner, repoName, skill.BlobSHA) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + parsed, parseErr := frontmatter.Parse(content) + if parseErr == nil { + content = parsed.Body + } + + rendered, err := markdown.Render(content, + markdown.WithTheme(opts.IO.TerminalTheme()), + markdown.WithWrap(opts.IO.TerminalWidth()), + markdown.WithoutIndentation()) + if err != nil { + rendered = content + } + + // Collect extra files (everything that isn't SKILL.md) + var extraFiles []discovery.SkillFile + for _, f := range files { + if f.Path != "SKILL.md" { + extraFiles = append(extraFiles, f) + } + } + + canPrompt := opts.IO.CanPrompt() + + // Non-interactive or skill has only SKILL.md: dump through pager + if !canPrompt || len(extraFiles) == 0 { + return renderAllFiles(opts, cs, skill, files, rendered, extraFiles, apiClient, hostname, owner, repoName) + } + + // Interactive with multiple files: show tree, then file picker + return renderInteractive(opts, cs, skill, files, rendered, extraFiles, apiClient, hostname, owner, repoName) +} + +// renderAllFiles dumps the tree, SKILL.md, and all extra files through the pager. +func renderAllFiles(opts *previewOptions, cs *iostreams.ColorScheme, skill discovery.Skill, + files []discovery.SkillFile, rendered string, extraFiles []discovery.SkillFile, + apiClient *api.Client, hostname, owner, repo string) error { + + opts.IO.DetectTerminalTheme() + if err := opts.IO.StartPager(); err != nil { + fmt.Fprintf(opts.IO.ErrOut, "starting pager failed: %v\n", err) + } + defer opts.IO.StopPager() + + out := opts.IO.Out + + if len(files) > 0 { + fmt.Fprintf(out, "%s\n", cs.Bold(skill.DisplayName()+"/")) + renderFileTree(out, cs, files) + fmt.Fprintln(out) + } + + fmt.Fprintf(out, "%s\n\n", cs.Bold("── SKILL.md ──")) + fmt.Fprint(out, rendered) + + const maxFiles = 20 + const maxTotalBytes = 512 * 1024 + fetched := 0 + totalBytes := 0 + for _, f := range extraFiles { + if fetched >= maxFiles { + fmt.Fprintf(out, "\n%s\n", cs.Gray(fmt.Sprintf("(skipped remaining files — showing first %d)", maxFiles))) + break + } + if totalBytes+f.Size > maxTotalBytes && fetched > 0 { + fmt.Fprintf(out, "\n%s\n", cs.Gray("(skipped remaining files — size limit reached)")) + break + } + fileContent, fetchErr := discovery.FetchBlob(apiClient, hostname, owner, repo, f.SHA) + if fetchErr != nil { + fmt.Fprintf(out, "\n%s\n\n%s\n", cs.Bold("── "+f.Path+" ──"), cs.Gray("(could not fetch file)")) + continue + } + fetched++ + totalBytes += len(fileContent) + fmt.Fprintf(out, "\n%s\n\n", cs.Bold("── "+f.Path+" ──")) + fmt.Fprint(out, fileContent) + if !strings.HasSuffix(fileContent, "\n") { + fmt.Fprintln(out) + } + } + + return nil +} + +// renderInteractive shows the file tree, then a picker to browse individual files. +func renderInteractive(opts *previewOptions, cs *iostreams.ColorScheme, skill discovery.Skill, + files []discovery.SkillFile, renderedSkillMD string, extraFiles []discovery.SkillFile, + apiClient *api.Client, hostname, owner, repo string) error { + + // Show the file tree to stderr so it persists above the prompt + fmt.Fprintf(opts.IO.ErrOut, "\n%s\n", cs.Bold(skill.DisplayName()+"/")) + renderFileTree(opts.IO.ErrOut, cs, files) + fmt.Fprintln(opts.IO.ErrOut) + + // Build choices: SKILL.md first, then extra files + choices := make([]string, 0, len(extraFiles)+1) + choices = append(choices, "SKILL.md") + for _, f := range extraFiles { + choices = append(choices, f.Path) + } + + // Save original stdout — StopPager closes IO.Out, so we need to + // restore a working writer before each StartPager call. + originalOut := opts.IO.Out + + for { + // Restore original Out before each pager cycle. StartPager replaces + // IO.Out with a pipe; StopPager closes that pipe but does not + // restore the original. The original writer remains valid. + opts.IO.Out = originalOut + + idx, err := opts.Prompter.Select("View a file (Esc to exit):", "", choices) + if err != nil { + return nil //nolint:nilerr // Prompter returns error on Esc/Ctrl-C; treat as graceful exit + } + + var content string + + if idx == 0 { + content = renderedSkillMD + } else { + selectedFile := extraFiles[idx-1] + + // Fetch on demand — don't hold blob data in memory + fileContent, fetchErr := discovery.FetchBlob(apiClient, hostname, owner, repo, selectedFile.SHA) + if fetchErr != nil { + fmt.Fprintf(opts.IO.ErrOut, "%s could not fetch %s: %v\n", cs.Red("!"), selectedFile.Path, fetchErr) + continue + } + content = fileContent + if !strings.HasSuffix(content, "\n") { + content += "\n" + } + } + + if err := opts.IO.StartPager(); err != nil { + fmt.Fprintf(opts.IO.ErrOut, "starting pager failed: %v\n", err) + } + fmt.Fprint(opts.IO.Out, content) + opts.IO.StopPager() + } +} + +func selectSkill(opts *previewOptions, skills []discovery.Skill) (discovery.Skill, error) { + if opts.SkillName != "" { + for _, s := range skills { + if s.DisplayName() == opts.SkillName || s.Name == opts.SkillName { + return s, nil + } + } + return discovery.Skill{}, fmt.Errorf("skill %q not found in %s", opts.SkillName, ghrepo.FullName(opts.repo)) + } + + if !opts.IO.CanPrompt() { + return discovery.Skill{}, fmt.Errorf("must specify a skill name when not running interactively") + } + + choices := make([]string, len(skills)) + for i, s := range skills { + choices[i] = s.DisplayName() + } + + idx, err := opts.Prompter.Select("Select a skill to preview:", "", choices) + if err != nil { + return discovery.Skill{}, err + } + + return skills[idx], nil +} + +// treeNode represents a file or directory in the tree for rendering. +type treeNode struct { + name string + children []*treeNode + isDir bool +} + +// renderFileTree prints a tree of skill files using box-drawing characters. +func renderFileTree(w io.Writer, cs *iostreams.ColorScheme, files []discovery.SkillFile) { + root := buildTree(files) + printTree(w, cs, root.children, "") +} + +// buildTree constructs a tree structure from flat file paths. +func buildTree(files []discovery.SkillFile) *treeNode { + root := &treeNode{isDir: true} + for _, f := range files { + parts := strings.Split(f.Path, "/") + current := root + for i, part := range parts { + isLast := i == len(parts)-1 + found := false + for _, child := range current.children { + if child.name == part { + current = child + found = true + break + } + } + if !found { + node := &treeNode{name: part, isDir: !isLast} + current.children = append(current.children, node) + current = node + } + } + } + sortTree(root) + return root +} + +func sortTree(node *treeNode) { + sort.Slice(node.children, func(i, j int) bool { + if node.children[i].isDir != node.children[j].isDir { + return node.children[i].isDir + } + return node.children[i].name < node.children[j].name + }) + for _, child := range node.children { + if child.isDir { + sortTree(child) + } + } +} + +func printTree(w io.Writer, cs *iostreams.ColorScheme, nodes []*treeNode, indent string) { + for i, node := range nodes { + isLast := i == len(nodes)-1 + connector := "├── " + childIndent := "│ " + if isLast { + connector = "└── " + childIndent = " " + } + if node.isDir { + fmt.Fprintf(w, "%s%s%s\n", indent, cs.Gray(connector), cs.Bold(node.name+"/")) + printTree(w, cs, node.children, indent+cs.Gray(childIndent)) + } else { + fmt.Fprintf(w, "%s%s%s\n", indent, cs.Gray(connector), node.name) + } + } +} diff --git a/pkg/cmd/skills/preview/preview_test.go b/pkg/cmd/skills/preview/preview_test.go new file mode 100644 index 000000000..b77455828 --- /dev/null +++ b/pkg/cmd/skills/preview/preview_test.go @@ -0,0 +1,466 @@ +package preview + +import ( + "encoding/base64" + "fmt" + "net/http" + "testing" + + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func TestNewCmdPreview(t *testing.T) { + tests := []struct { + name string + input string + wantRepo string + wantSkillName string + wantErr bool + }{ + { + name: "repo and skill", + input: "github/awesome-copilot my-skill", + wantRepo: "github/awesome-copilot", + wantSkillName: "my-skill", + }, + { + name: "repo only", + input: "github/awesome-copilot", + wantRepo: "github/awesome-copilot", + }, + { + name: "no args", + input: "", + wantErr: true, + }, + { + name: "too many args", + input: "a b c", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + Prompter: &prompter.PrompterMock{}, + } + + var gotOpts *previewOptions + cmd := NewCmdPreview(f, func(opts *previewOptions) error { + gotOpts = opts + return nil + }) + + args, _ := shlex.Split(tt.input) + cmd.SetArgs(args) + cmd.SetOut(&discardWriter{}) + cmd.SetErr(&discardWriter{}) + err := cmd.Execute() + + if tt.wantErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.wantRepo, gotOpts.RepoArg) + assert.Equal(t, tt.wantSkillName, gotOpts.SkillName) + }) + } +} + +func TestNewCmdPreview_Alias(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}} + cmd := NewCmdPreview(f, func(_ *previewOptions) error { return nil }) + assert.Contains(t, cmd.Aliases, "show") +} + +func TestPreviewRun(t *testing.T) { + skillContent := "---\nname: my-skill\ndescription: A test skill\n---\n# My Skill\n\nThis is the skill content." + encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent)) + + tests := []struct { + name string + opts *previewOptions + tty bool + httpStubs func(*httpmock.Registry) + wantStdout string + wantErr string + }{ + { + name: "preview specific skill", + tty: true, + opts: &previewOptions{ + repo: ghrepo.New("github", "awesome-copilot"), + SkillName: "my-skill", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/github/awesome-copilot/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/github/awesome-copilot/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/github/awesome-copilot/git/trees/abc123"), + httpmock.StringResponse(`{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": "skills", "type": "tree", "sha": "tree1"}, + {"path": "skills/my-skill", "type": "tree", "sha": "treeSHA"}, + {"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blob123"} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/github/awesome-copilot/git/trees/treeSHA"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blob123", "size": 50} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/github/awesome-copilot/git/blobs/blob123"), + httpmock.StringResponse(`{"sha": "blob123", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + }, + wantStdout: "My Skill", + }, + { + name: "preview with display name match", + tty: true, + opts: &previewOptions{ + repo: ghrepo.New("owner", "repo"), + SkillName: "ns/my-skill", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(`{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": "skills", "type": "tree", "sha": "tree1"}, + {"path": "skills/ns", "type": "tree", "sha": "tree-ns"}, + {"path": "skills/ns/my-skill", "type": "tree", "sha": "treeSHA2"}, + {"path": "skills/ns/my-skill/SKILL.md", "type": "blob", "sha": "blob456"} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA2"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blob456", "size": 50} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blob456"), + httpmock.StringResponse(`{"sha": "blob456", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + }, + wantStdout: "My Skill", + }, + { + name: "skill not found", + tty: true, + opts: &previewOptions{ + repo: ghrepo.New("owner", "repo"), + SkillName: "nonexistent", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(`{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": "skills/my-skill", "type": "tree", "sha": "tree2"}, + {"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blob123"} + ] + }`), + ) + }, + wantErr: `skill "nonexistent" not found in owner/repo`, + }, + { + name: "no skill name non-interactive errors", + tty: false, + opts: &previewOptions{ + repo: ghrepo.New("owner", "repo"), + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(`{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": "skills/my-skill", "type": "tree", "sha": "tree2"}, + {"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blob123"} + ] + }`), + ) + }, + wantErr: "must specify a skill name when not running interactively", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(tt.tty) + ios.SetStdinTTY(tt.tty) + tt.opts.IO = ios + + tt.opts.Prompter = &prompter.PrompterMock{} + + err := previewRun(tt.opts) + + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + return + } + + assert.NoError(t, err) + if tt.wantStdout != "" { + assert.Contains(t, stdout.String(), tt.wantStdout) + } + }) + } +} + +func TestPreviewRun_Interactive(t *testing.T) { + skillContent := "# Selected Skill\n\nContent here." + encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent)) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(`{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": "skills/alpha", "type": "tree", "sha": "tree-a"}, + {"path": "skills/alpha/SKILL.md", "type": "blob", "sha": "blob-a"}, + {"path": "skills/beta", "type": "tree", "sha": "tree-b"}, + {"path": "skills/beta/SKILL.md", "type": "blob", "sha": "blob-b"} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/tree-b"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blob-b", "size": 40} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blob-b"), + httpmock.StringResponse(`{"sha": "blob-b", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + + pm := &prompter.PrompterMock{ + SelectFunc: func(prompt string, defaultValue string, options []string) (int, error) { + assert.Equal(t, "Select a skill to preview:", prompt) + assert.Equal(t, []string{"alpha", "beta"}, options) + return 1, nil // select "beta" + }, + } + + opts := &previewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + repo: ghrepo.New("owner", "repo"), + } + + err := previewRun(opts) + assert.NoError(t, err) + assert.Contains(t, stdout.String(), "Selected Skill") +} + +func TestPreviewRun_ShowsFileTree(t *testing.T) { + skillContent := "---\nname: my-skill\ndescription: test\n---\n# My Skill\nBody." + encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent)) + + scriptContent := "#!/bin/bash\necho hello" + encodedScript := base64.StdEncoding.EncodeToString([]byte(scriptContent)) + + makeReg := func() *httpmock.Registry { + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(`{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": "skills/my-skill", "type": "tree", "sha": "treeSHA"}, + {"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobSKILL"}, + {"path": "skills/my-skill/scripts", "type": "tree", "sha": "treeScripts"}, + {"path": "skills/my-skill/scripts/run.sh", "type": "blob", "sha": "blobScript"} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blobSKILL", "size": 50}, + {"path": "scripts", "type": "tree", "sha": "treeScripts"}, + {"path": "scripts/run.sh", "type": "blob", "sha": "blobScript", "size": 20} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blobSKILL"), + httpmock.StringResponse(`{"sha": "blobSKILL", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blobScript"), + httpmock.StringResponse(`{"sha": "blobScript", "content": "`+encodedScript+`", "encoding": "base64"}`), + ) + return reg + } + + t.Run("interactive file picker", func(t *testing.T) { + reg := makeReg() + defer reg.Verify(t) + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetColorEnabled(false) + + selectCalls := 0 + pm := &prompter.PrompterMock{ + SelectFunc: func(prompt string, defaultValue string, options []string) (int, error) { + selectCalls++ + if selectCalls == 1 { + // Options: ["SKILL.md", "scripts/run.sh"] + assert.Equal(t, "SKILL.md", options[0]) + assert.Equal(t, "scripts/run.sh", options[1]) + // Select "scripts/run.sh" + return 1, nil + } + // Simulate Esc/Ctrl-C to exit + return 0, fmt.Errorf("user cancelled") + }, + } + + opts := &previewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + repo: ghrepo.New("owner", "repo"), + SkillName: "my-skill", + } + + err := previewRun(opts) + assert.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "echo hello") + assert.Equal(t, 2, selectCalls) + }) + + t.Run("non-interactive dumps all files", func(t *testing.T) { + reg := makeReg() + defer reg.Verify(t) + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + ios.SetColorEnabled(false) + + opts := &previewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: &prompter.PrompterMock{}, + repo: ghrepo.New("owner", "repo"), + SkillName: "my-skill", + } + + err := previewRun(opts) + assert.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "my-skill/") + assert.Contains(t, out, "My Skill") + assert.Contains(t, out, "scripts/run.sh") + assert.Contains(t, out, "echo hello") + }) +} + +// discardWriter is a no-op writer for suppressing cobra output in tests. +type discardWriter struct{} + +func (d *discardWriter) Write(p []byte) (int, error) { return len(p), nil } diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go new file mode 100644 index 000000000..9a9200131 --- /dev/null +++ b/pkg/cmd/skills/publish/publish.go @@ -0,0 +1,1246 @@ +package publish + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/git" + giturl "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/internal/skills/frontmatter" + "github.com/cli/cli/v2/internal/skills/registry" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +// publishOptions holds all dependencies and user-provided flags for the publish command. +type publishOptions struct { + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + Config func() (gh.Config, error) + Prompter prompter.Prompter + GitClient *git.Client + + // Arguments + Dir string // directory to validate (default: cwd) + + // Flags + Fix bool // --fix flag: auto-fix issues where possible + Plugins bool // --plugins flag: generate .claude-plugin/ manifest + DryRun bool // --dry-run flag: validate only, don't publish + Tag string // --tag flag: release tag to create + + // Testing overrides + client *api.Client // injectable for tests; nil means use factory HttpClient + host string // API host (e.g. "github.com"); resolved from config in production +} + +// publishDiagnostic is a single validation finding. +type publishDiagnostic struct { + skill string // empty for repo-level issues + severity string // "error", "warning", "fixed", or "info" + message string +} + +// repoTopicsResponse is the response from the repo topics API. +type repoTopicsResponse struct { + Names []string `json:"names"` +} + +// tagEntry is a single tag from the tags list API. +type tagEntry struct { + Name string `json:"name"` +} + +// rulesetsResponse is a single ruleset from the rulesets API. +type rulesetsResponse struct { + ID int `json:"id"` + Name string `json:"name"` + Target string `json:"target"` + Enforcement string `json:"enforcement"` +} + +// securityAnalysis represents the security_and_analysis field from the repo API. +type securityAnalysis struct { + AdvancedSecurity *securityFeature `json:"advanced_security"` + SecretScanning *securityFeature `json:"secret_scanning"` + SecretScanningPushProtection *securityFeature `json:"secret_scanning_push_protection"` +} + +type securityFeature struct { + Status string `json:"status"` +} + +// repoSecurityResponse is the subset of repo API we need for security checks. +type repoSecurityResponse struct { + SecurityAndAnalysis *securityAnalysis `json:"security_and_analysis"` +} + +// NewCmdPublish creates the "skills publish" command. +func NewCmdPublish(f *cmdutil.Factory, runF func(*publishOptions) error) *cobra.Command { + opts := &publishOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + Prompter: f.Prompter, + GitClient: f.GitClient, + } + + cmd := &cobra.Command{ + Use: "publish []", + Short: "Validate and publish skills to a GitHub repository", + Long: heredoc.Doc(` + Validate a local repository's skills against the Agent Skills specification + and publish them by creating a GitHub release. + + Validation checks include: + + - Skills follow the skills/*/SKILL.md directory convention + - Skill names match the strict agentskills.io naming rules + - Each skill name matches its directory name + - Required frontmatter fields (name, description) are present + - allowed-tools is a string, not an array + - Install metadata (metadata.github-*) is stripped if present + + After validation passes, publish will interactively guide you through: + + - Adding the "agent-skills" topic to the repository + - Choosing a version tag (semver recommended) + - Creating a GitHub release with auto-generated notes + + Use --dry-run to validate without publishing. + Use --tag to publish non-interactively with a specific tag. + Use --fix to automatically strip install metadata from committed files. + Use --plugins to generate a .claude-plugin/plugin.json manifest for + Claude Code plugin discovery. + `), + Example: heredoc.Doc(` + # Validate and publish interactively + $ gh skills publish + + # Publish with a specific tag (non-interactive) + $ gh skills publish --tag v1.0.0 + + # Validate only (no publish) + $ gh skills publish --dry-run + + # Validate and strip install metadata + $ gh skills publish --fix + + # Generate Claude Code plugin manifest + $ gh skills publish --plugins + `), + Aliases: []string{"validate"}, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 1 { + opts.Dir = args[0] + } + if runF != nil { + return runF(opts) + } + return publishRun(opts) + }, + } + + cmd.Flags().BoolVar(&opts.Fix, "fix", false, "Auto-fix issues where possible (e.g. strip install metadata)") + cmd.Flags().BoolVar(&opts.Plugins, "plugins", false, "Generate .claude-plugin/ manifest for Claude Code plugin discovery") + cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Validate without publishing") + cmd.Flags().StringVar(&opts.Tag, "tag", "", "Version tag for the release (e.g. v1.0.0)") + + return cmd +} + +func publishRun(opts *publishOptions) error { + dir := opts.Dir + if dir == "" { + var err error + dir, err = os.Getwd() + if err != nil { + return fmt.Errorf("could not determine working directory: %w", err) + } + } + + dir, err := filepath.Abs(dir) + if err != nil { + return fmt.Errorf("could not resolve path: %w", err) + } + + cs := opts.IO.ColorScheme() + canPrompt := opts.IO.CanPrompt() + + // Use injected client or create one from the factory HttpClient + client := opts.client + host := opts.host + + var diagnostics []publishDiagnostic + + // Check for skills directory + skillsDir := filepath.Join(dir, "skills") + info, err := os.Stat(skillsDir) + if err != nil || !info.IsDir() { + return fmt.Errorf("no skills/ directory found in %s; run this command from a repository root containing a skills/ directory", dir) + } + + // Discover skill directories + entries, err := os.ReadDir(skillsDir) + if err != nil { + return fmt.Errorf("could not read skills/ directory: %w", err) + } + + var skillDirs []string + for _, e := range entries { + if e.IsDir() { + skillDirs = append(skillDirs, e.Name()) + } + } + + if len(skillDirs) == 0 { + return fmt.Errorf("no skill directories found in %s/skills/", dir) + } + + for _, dirName := range skillDirs { + skillPath := filepath.Join(skillsDir, dirName, "SKILL.md") + content, err := os.ReadFile(skillPath) + if err != nil { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: dirName, + severity: "error", + message: "missing SKILL.md file", + }) + continue + } + + result, err := frontmatter.Parse(string(content)) + if err != nil { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: dirName, + severity: "error", + message: fmt.Sprintf("invalid frontmatter YAML: %s", err), + }) + continue + } + + // Validate name field exists + if result.Metadata.Name == "" { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: dirName, + severity: "error", + message: "missing required field: name", + }) + } else { + // Validate name matches directory + if result.Metadata.Name != dirName { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: dirName, + severity: "error", + message: fmt.Sprintf("name %q does not match directory name %q", result.Metadata.Name, dirName), + }) + } + + // Validate name is spec-compliant + if !discovery.IsSpecCompliant(result.Metadata.Name) { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: dirName, + severity: "error", + message: fmt.Sprintf("name %q does not follow agentskills.io naming convention (lowercase alphanumeric + hyphens)", result.Metadata.Name), + }) + } + } + + // Validate description field exists + if result.Metadata.Description == "" { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: dirName, + severity: "error", + message: "missing required field: description", + }) + } else if len(result.Metadata.Description) > 1024 { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: dirName, + severity: "warning", + message: fmt.Sprintf("description is %d chars (recommended max: 1024)", len(result.Metadata.Description)), + }) + } + + // Validate allowed-tools is string, not array + if raw, ok := result.RawYAML["allowed-tools"]; ok { + if _, isSlice := raw.([]interface{}); isSlice { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: dirName, + severity: "error", + message: "allowed-tools must be a string (space-delimited), not an array", + }) + } + } + + // Check for install metadata that should be stripped + if meta, ok := result.RawYAML["metadata"].(map[string]interface{}); ok { + githubKeys := findGitHubMetadataKeys(meta) + if len(githubKeys) > 0 { + if opts.Fix { + fixed, fixErr := stripGitHubMetadata(string(content)) + if fixErr != nil { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: dirName, + severity: "error", + message: fmt.Sprintf("could not strip install metadata: %s", fixErr), + }) + } else if writeErr := os.WriteFile(skillPath, []byte(fixed), 0o644); writeErr != nil { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: dirName, + severity: "error", + message: fmt.Sprintf("could not write fixed SKILL.md: %s", writeErr), + }) + } else { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: dirName, + severity: "fixed", + message: fmt.Sprintf("stripped install metadata: %s", strings.Join(githubKeys, ", ")), + }) + } + } else { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: dirName, + severity: "error", + message: fmt.Sprintf("contains install metadata that must be stripped: %s (use --fix)", strings.Join(githubKeys, ", ")), + }) + } + } + } + + // Recommended: license field + if result.Metadata.License == "" { + if _, ok := result.RawYAML["license"]; !ok { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: dirName, + severity: "warning", + message: "recommended field missing: license", + }) + } + } + + // Recommended: body length + bodyLines := strings.Count(result.Body, "\n") + 1 + if bodyLines > 500 { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: dirName, + severity: "warning", + message: fmt.Sprintf("skill body is %d lines (recommended max: 500 for efficient context)", bodyLines), + }) + } + } + + // Check for installed skill directories that should be gitignored + installedDirDiags := checkInstalledSkillDirs(opts.GitClient, dir) + diagnostics = append(diagnostics, installedDirDiags...) + + // Remote repository checks (best-effort) + owner, repo := detectGitHubRemote(opts.GitClient) + hasTopic := false + var existingTags []tagEntry + if owner != "" && repo != "" { + // Create API client for remote checks if not already injected + if client == nil { + httpClient, httpErr := opts.HttpClient() + if httpErr == nil { + apiClient := api.NewClientFromHTTP(httpClient) + cfg, cfgErr := opts.Config() + if cfgErr == nil { + host, _ = cfg.Authentication().DefaultHost() + client = apiClient + } + } + } + + if client != nil { + // Security and ruleset checks (advisory, always shown) + securityDiags := checkSecuritySettings(client, host, owner, repo, skillsDir) + diagnostics = append(diagnostics, securityDiags...) + + rulesetDiags := checkTagProtection(client, host, owner, repo) + diagnostics = append(diagnostics, rulesetDiags...) + + // Check topic (needed for publish flow, not a blocking error) + hasTopic = repoHasTopic(client, host, owner, repo) + + // Fetch existing tags (needed for version suggestion) + existingTags = fetchTags(client, host, owner, repo) + } + } else { + diagnostics = append(diagnostics, detectMissingRepoDiagnostic(opts.GitClient, dir)...) + } + + // Render diagnostics + errors, warnings, fixes := 0, 0, 0 + for _, d := range diagnostics { + switch d.severity { + case "error": + errors++ + case "warning": + warnings++ + case "fixed": + fixes++ + } + } + + if canPrompt { + renderDiagnosticsTTY(opts, skillDirs, diagnostics, errors, warnings, fixes, owner, repo) + } else { + renderDiagnosticsPlain(opts, diagnostics, errors, warnings) + } + + // Generate Claude Code plugin manifest if requested + if opts.Plugins { + pluginDiags := generateClaudePlugin(dir, skillDirs, owner, repo) + for _, d := range pluginDiags { + switch d.severity { + case "error": + fmt.Fprintf(opts.IO.ErrOut, "%s %s\n", cs.FailureIcon(), d.message) + default: + fmt.Fprintf(opts.IO.Out, "%s %s\n", cs.SuccessIcon(), d.message) + } + } + } + + if errors > 0 { + return fmt.Errorf("validation failed with %d error(s)", errors) + } + + // --- Publish flow --- + if opts.DryRun { + fmt.Fprintf(opts.IO.ErrOut, "\nDry run complete. Use without --dry-run to publish.\n") + return nil + } + + if owner == "" || repo == "" { + fmt.Fprintf(opts.IO.ErrOut, "\nValidation passed. Set up a GitHub remote to publish.\n") + return nil + } + + if !canPrompt && opts.Tag == "" { + fmt.Fprintf(opts.IO.ErrOut, "\nValidation passed. Use --tag to publish non-interactively.\n") + return nil + } + + if client == nil { + fmt.Fprintf(opts.IO.ErrOut, "\nValidation passed but could not create API client. Check your authentication configuration.\n") + return nil + } + + fmt.Fprintf(opts.IO.ErrOut, "\nPublishing to %s/%s...\n\n", owner, repo) + + return runPublishRelease(opts, client, host, owner, repo, dir, hasTopic, existingTags) +} + +// repoHasTopic checks whether the repo has the agent-skills topic. +func repoHasTopic(client *api.Client, host, owner, repo string) bool { + if client == nil { + return false + } + apiPath := fmt.Sprintf("repos/%s/%s/topics", owner, repo) + var resp repoTopicsResponse + if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { + return false + } + for _, t := range resp.Names { + if t == "agent-skills" { + return true + } + } + return false +} + +// fetchTags returns the most recent tags from the repo. +func fetchTags(client *api.Client, host, owner, repo string) []tagEntry { + if client == nil { + return nil + } + apiPath := fmt.Sprintf("repos/%s/%s/tags?per_page=10", owner, repo) + var tags []tagEntry + if err := client.REST(host, "GET", apiPath, nil, &tags); err != nil { + return nil + } + return tags +} + +// runPublishRelease handles the interactive publish flow: topic, tag, release, immutability. +func runPublishRelease(opts *publishOptions, client *api.Client, host, owner, repo, dir string, hasTopic bool, existingTags []tagEntry) error { + cs := opts.IO.ColorScheme() + canPrompt := opts.IO.CanPrompt() + + // 1. Add topic if missing + if !hasTopic { + addTopic := true + if canPrompt { + var err error + addTopic, err = opts.Prompter.Confirm( + fmt.Sprintf("Add \"agent-skills\" topic to %s/%s? (required for discoverability)", owner, repo), true) + if err != nil { + return err + } + } + if addTopic { + if err := addAgentSkillsTopic(client, host, owner, repo); err != nil { + fmt.Fprintf(opts.IO.ErrOut, "%s Could not add topic: %v\n", cs.WarningIcon(), err) + fmt.Fprintf(opts.IO.ErrOut, " Add it manually: gh repo edit %s/%s --add-topic agent-skills\n", owner, repo) + } else { + fmt.Fprintf(opts.IO.Out, "%s Added \"agent-skills\" topic\n", cs.SuccessIcon()) + } + } + } + + // 2. Determine tag + tag := opts.Tag + if tag == "" { + suggested := "v1.0.0" + if len(existingTags) > 0 { + if next := suggestNextTag(existingTags[0].Name); next != "" { + suggested = next + } + } + + if canPrompt { + strategies := []string{ + fmt.Sprintf("Semver (recommended) — %s", suggested), + "Custom tag", + } + idx, err := opts.Prompter.Select("Tagging strategy:", "", strategies) + if err != nil { + return err + } + + if idx == 0 { + tag = suggested + edited, err := opts.Prompter.Input(fmt.Sprintf("Version tag [%s]:", suggested), suggested) + if err != nil { + return err + } + if edited != "" { + tag = edited + } + } else { + custom, err := opts.Prompter.Input("Tag:", "") + if err != nil { + return err + } + if custom == "" { + return fmt.Errorf("tag is required") + } + tag = custom + } + } else { + return fmt.Errorf("--tag is required for non-interactive publish") + } + } + + // Validate tag doesn't already exist + for _, t := range existingTags { + if t.Name == tag { + return fmt.Errorf("tag %s already exists — choose a different version", tag) + } + } + + // 3. Offer to enable immutable releases + immutableEnabled := checkImmutableReleases(client, host, owner, repo) + if !immutableEnabled && canPrompt { + enableImmutable, err := opts.Prompter.Confirm( + "Enable immutable releases? (prevents tampering with published releases)", true) + if err != nil { + return err + } + if enableImmutable { + if err := enableImmutableReleases(client, host, owner, repo); err != nil { + fmt.Fprintf(opts.IO.ErrOut, "%s Could not enable immutable releases: %v\n", cs.WarningIcon(), err) + fmt.Fprintf(opts.IO.ErrOut, " Enable manually in Settings → General → Releases\n") + } else { + fmt.Fprintf(opts.IO.Out, "%s Enabled immutable releases\n", cs.SuccessIcon()) + } + } + } + + // 4. Inform if not on default branch + var currentBranch string + if opts.GitClient != nil { + bc := *opts.GitClient + bc.RepoDir = dir + if b, err := bc.CurrentBranch(context.Background()); err == nil { + currentBranch = b + } + } + defaultBranch := detectDefaultBranch(client, host, owner, repo) + if currentBranch != "" && defaultBranch != "" && currentBranch != defaultBranch { + fmt.Fprintf(opts.IO.ErrOut, "%s Publishing from branch %q (default is %q)\n", cs.WarningIcon(), currentBranch, defaultBranch) + } + + // 5. Confirm and create release + if canPrompt { + confirmed, err := opts.Prompter.Confirm( + fmt.Sprintf("Create release %s with auto-generated notes?", tag), true) + if err != nil { + return err + } + if !confirmed { + fmt.Fprintf(opts.IO.ErrOut, "Publish cancelled.\n") + return nil + } + } + + // Create release via REST API + releaseBody := map[string]interface{}{ + "tag_name": tag, + "generate_release_notes": true, + } + if currentBranch != "" { + releaseBody["target_commitish"] = currentBranch + } + releaseJSON, err := json.Marshal(releaseBody) + if err != nil { + return fmt.Errorf("failed to serialize release request: %w", err) + } + + releasePath := fmt.Sprintf("repos/%s/%s/releases", owner, repo) + var releaseResp struct { + HTMLURL string `json:"html_url"` + } + if err := client.REST(host, "POST", releasePath, bytes.NewReader(releaseJSON), &releaseResp); err != nil { + return fmt.Errorf("failed to create release: %w", err) + } + + fmt.Fprintf(opts.IO.Out, "%s Published %s\n", cs.SuccessIcon(), tag) + fmt.Fprintf(opts.IO.Out, "%s Install with: gh skills install %s/%s\n", cs.SuccessIcon(), owner, repo) + fmt.Fprintf(opts.IO.Out, "%s Pin with: gh skills install %s/%s --pin %s\n", cs.SuccessIcon(), owner, repo, tag) + + return nil +} + +// detectDefaultBranch returns the default branch of the remote repo via the API. +func detectDefaultBranch(client *api.Client, host, owner, repo string) string { + if client == nil { + return "" + } + var result struct { + DefaultBranch string `json:"default_branch"` + } + if err := client.REST(host, "GET", fmt.Sprintf("repos/%s/%s", owner, repo), nil, &result); err != nil { + return "" + } + return result.DefaultBranch +} + +// addAgentSkillsTopic adds the "agent-skills" topic to the repo, preserving existing topics. +func addAgentSkillsTopic(client *api.Client, host, owner, repo string) error { + apiPath := fmt.Sprintf("repos/%s/%s/topics", owner, repo) + + // Fetch existing topics + var resp repoTopicsResponse + if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { + return fmt.Errorf("could not fetch existing topics: %w", err) + } + + // Deduplicate: only add if not already present + for _, t := range resp.Names { + if t == "agent-skills" { + return nil + } + } + + topics := append(resp.Names, "agent-skills") + topicsJSON, err := json.Marshal(map[string][]string{"names": topics}) + if err != nil { + return fmt.Errorf("could not serialize topics: %w", err) + } + return client.REST(host, "PUT", apiPath, bytes.NewReader(topicsJSON), nil) +} + +// checkImmutableReleases checks if immutable releases are enabled for the repo. +func checkImmutableReleases(client *api.Client, host, owner, repo string) bool { + if client == nil { + return false + } + apiPath := fmt.Sprintf("repos/%s/%s/immutable-releases", owner, repo) + var resp struct { + Enabled bool `json:"enabled"` + } + if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { + return false + } + return resp.Enabled +} + +// enableImmutableReleases enables immutable releases for the repo. +func enableImmutableReleases(client *api.Client, host, owner, repo string) error { + apiPath := fmt.Sprintf("repos/%s/%s/immutable-releases", owner, repo) + body := bytes.NewReader([]byte(`{"enabled":true}`)) + return client.REST(host, "PATCH", apiPath, body, nil) +} + +// checkTagProtection checks whether tag protection rulesets are enabled. +func checkTagProtection(client *api.Client, host, owner, repo string) []publishDiagnostic { + if client == nil { + return nil + } + apiPath := fmt.Sprintf("repos/%s/%s/rulesets", owner, repo) + var rulesets []rulesetsResponse + if err := client.REST(host, "GET", apiPath, nil, &rulesets); err != nil { + return nil + } + + for _, rs := range rulesets { + if rs.Target == "tag" && rs.Enforcement == "active" { + return nil + } + } + + return []publishDiagnostic{{ + severity: "warning", + message: "no active tag protection rulesets found — consider protecting tags to ensure immutable releases (Settings → Rules → Rulesets)", + }} +} + +// checkSecuritySettings checks whether recommended security features are enabled. +func checkSecuritySettings(client *api.Client, host, owner, repo, skillsDir string) []publishDiagnostic { + if client == nil { + return nil + } + apiPath := fmt.Sprintf("repos/%s/%s", owner, repo) + var resp repoSecurityResponse + if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { + return nil + } + + if resp.SecurityAndAnalysis == nil { + return nil + } + + var diagnostics []publishDiagnostic + sa := resp.SecurityAndAnalysis + + if sa.SecretScanning == nil || sa.SecretScanning.Status != "enabled" { + diagnostics = append(diagnostics, publishDiagnostic{ + severity: "warning", + message: "secret scanning is not enabled — recommended to prevent accidental credential exposure (gh repo edit --enable-secret-scanning)", + }) + } + + if sa.SecretScanningPushProtection == nil || sa.SecretScanningPushProtection.Status != "enabled" { + diagnostics = append(diagnostics, publishDiagnostic{ + severity: "warning", + message: "secret scanning push protection is not enabled — blocks pushes containing secrets (gh repo edit --enable-secret-scanning-push-protection)", + }) + } + + hasCode, hasManifests := detectCodeAndManifests(skillsDir) + + if hasCode { + alertsPath := fmt.Sprintf("repos/%s/%s/code-scanning/alerts?per_page=1&state=open", owner, repo) + if err := client.REST(host, "GET", alertsPath, nil, new([]interface{})); err != nil { + diagnostics = append(diagnostics, publishDiagnostic{ + severity: "info", + message: "skills include code files but code scanning does not appear to be configured (Settings → Code security → Code scanning)", + }) + } + } + + if hasManifests { + dependabotPath := fmt.Sprintf("repos/%s/%s/vulnerability-alerts", owner, repo) + if err := client.REST(host, "GET", dependabotPath, nil, nil); err != nil { + diagnostics = append(diagnostics, publishDiagnostic{ + severity: "info", + message: "skills include dependency manifests but Dependabot alerts do not appear to be enabled (Settings → Code security → Dependabot)", + }) + } + } + + return diagnostics +} + +// codeExtensions are file extensions that indicate code is present. +var codeExtensions = map[string]bool{ + ".go": true, ".py": true, ".js": true, ".ts": true, ".rb": true, + ".rs": true, ".java": true, ".cs": true, ".sh": true, ".bash": true, + ".zsh": true, ".ps1": true, ".swift": true, ".kt": true, ".c": true, + ".cpp": true, ".h": true, ".php": true, ".pl": true, ".lua": true, +} + +// manifestFiles are dependency manifest filenames. +var manifestFiles = map[string]bool{ + "package.json": true, "package-lock.json": true, "yarn.lock": true, + "go.mod": true, "go.sum": true, "Cargo.toml": true, "Cargo.lock": true, + "requirements.txt": true, "Pipfile": true, "Pipfile.lock": true, + "pyproject.toml": true, "poetry.lock": true, "Gemfile": true, + "Gemfile.lock": true, "pom.xml": true, "build.gradle": true, + "composer.json": true, "composer.lock": true, +} + +// detectCodeAndManifests walks the skills directory looking for code files +// and dependency manifests. +func detectCodeAndManifests(skillsDir string) (hasCode, hasManifests bool) { + _ = filepath.Walk(skillsDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + ext := filepath.Ext(info.Name()) + if codeExtensions[ext] { + hasCode = true + } + if manifestFiles[info.Name()] { + hasManifests = true + } + if hasCode && hasManifests { + return filepath.SkipAll + } + return nil + }) + return +} + +// checkInstalledSkillDirs warns when agent host skill directories exist +// in the repo and are not gitignored. +func checkInstalledSkillDirs(gitClient *git.Client, repoDir string) []publishDiagnostic { + var diagnostics []publishDiagnostic + + for _, relPath := range registry.UniqueProjectDirs() { + absPath := filepath.Join(repoDir, relPath) + if _, err := os.Stat(absPath); os.IsNotExist(err) { + continue + } + + if gitClient != nil { + ic := *gitClient + ic.RepoDir = repoDir + if ic.IsIgnored(context.Background(), relPath) { + continue + } + } + + diagnostics = append(diagnostics, publishDiagnostic{ + severity: "warning", + message: fmt.Sprintf( + "%s/ contains installed skills and should be added to .gitignore to avoid publishing other authors' content", + relPath), + }) + } + + return diagnostics +} + +// semverPattern matches v-prefixed semver tags (e.g. v1.2.3). +var semverPattern = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)$`) + +// suggestNextTag increments the patch version of a semver tag. +func suggestNextTag(latest string) string { + m := semverPattern.FindStringSubmatch(latest) + if m == nil { + return "" + } + + prefix := "" + if strings.HasPrefix(latest, "v") { + prefix = "v" + } + + major, minor := m[1], m[2] + patch := 0 + fmt.Sscanf(m[3], "%d", &patch) + + return fmt.Sprintf("%s%s.%s.%d", prefix, major, minor, patch+1) +} + +// detectGitHubRemote attempts to detect the GitHub owner/repo from git remotes. +func detectGitHubRemote(gitClient *git.Client) (owner, repo string) { + if gitClient == nil { + return "", "" + } + + // Try origin first + if url, err := gitClient.RemoteURL(context.Background(), "origin"); err == nil { + if o, r := parseGitHubURL(url); o != "" { + return o, r + } + } + + // Fall back to any remote that points to GitHub + remotes, err := gitClient.Remotes(context.Background()) + if err != nil { + return "", "" + } + for _, r := range remotes { + if r.Name == "origin" { + continue + } + if url, err := gitClient.RemoteURL(context.Background(), r.Name); err == nil { + if o, rp := parseGitHubURL(url); o != "" { + return o, rp + } + } + } + return "", "" +} + +// parseGitHubURL extracts owner/repo from a GitHub remote URL. +// Only GitHub.com URLs are recognized. +func parseGitHubURL(rawURL string) (owner, repo string) { + u, err := giturl.ParseURL(rawURL) + if err != nil { + return "", "" + } + r, err := ghrepo.FromURL(u) + if err != nil { + return "", "" + } + // Only match github.com — the default GitHub host. + host := strings.ToLower(r.RepoHost()) + if host != ghinstance.Default() { + return "", "" + } + return r.RepoOwner(), r.RepoName() +} + +// detectMissingRepoDiagnostic explains why remote checks were skipped. +func detectMissingRepoDiagnostic(gitClient *git.Client, dir string) []publishDiagnostic { + if gitClient == nil { + return nil + } + + dc := *gitClient + dc.RepoDir = dir + if _, err := dc.GitDir(context.Background()); err != nil { + return []publishDiagnostic{{ + severity: "warning", + message: "not a git repository — initialize with: git init && gh repo create", + }} + } + + remotes, err := dc.Remotes(context.Background()) + if err != nil || len(remotes) == 0 { + return []publishDiagnostic{{ + severity: "warning", + message: "no git remote found — create a GitHub repository with: gh repo create", + }} + } + + var urls []string + for _, r := range remotes { + if url, err := dc.RemoteURL(context.Background(), r.Name); err == nil { + urls = append(urls, url) + } + } + return []publishDiagnostic{{ + severity: "warning", + message: fmt.Sprintf("remote %q is not a GitHub repository — skills must be hosted on GitHub for discovery", strings.Join(urls, ", ")), + }} +} + +func renderDiagnosticsTTY(opts *publishOptions, skillDirs []string, diagnostics []publishDiagnostic, errors, warnings, fixes int, owner, repo string) { + cs := opts.IO.ColorScheme() + + // Separate info messages from errors/warnings for cleaner output + var infos, issues []publishDiagnostic + for _, d := range diagnostics { + if d.severity == "info" { + infos = append(infos, d) + } else { + issues = append(issues, d) + } + } + + if len(issues) == 0 && fixes == 0 { + fmt.Fprintf(opts.IO.Out, "%s %d skill(s) validated successfully\n", cs.SuccessIcon(), len(skillDirs)) + } else { + for _, d := range issues { + var prefix string + switch d.severity { + case "error": + prefix = cs.FailureIcon() + case "warning": + prefix = cs.WarningIcon() + case "fixed": + prefix = cs.SuccessIcon() + default: + prefix = cs.FailureIcon() + } + if d.skill != "" { + fmt.Fprintf(opts.IO.Out, "%s %s: %s\n", prefix, cs.Bold(d.skill), d.message) + } else { + fmt.Fprintf(opts.IO.Out, "%s %s\n", prefix, d.message) + } + } + + fmt.Fprintln(opts.IO.Out) + if fixes > 0 { + fmt.Fprintf(opts.IO.Out, "Fixed %d issue(s)\n", fixes) + } + if errors > 0 { + fmt.Fprintf(opts.IO.Out, "%s, %s\n", + cs.Red(fmt.Sprintf("%d error(s)", errors)), + cs.Yellow(fmt.Sprintf("%d warning(s)", warnings))) + } else { + fmt.Fprintf(opts.IO.Out, "%s\n", cs.Yellow(fmt.Sprintf("%d warning(s)", warnings))) + } + } + + // Always show info messages + for _, d := range infos { + fmt.Fprintf(opts.IO.ErrOut, "\n%s\n", d.message) + } + + if errors == 0 { + if owner != "" && repo != "" { + fmt.Fprintf(opts.IO.ErrOut, "\n%s Repository: %s/%s\n", cs.Green("Ready to publish!"), owner, repo) + } else { + fmt.Fprintf(opts.IO.ErrOut, "\n%s Ensure the repository has the \"agent-skills\" topic.\n", cs.Green("Ready to publish!")) + } + } +} + +func renderDiagnosticsPlain(opts *publishOptions, diagnostics []publishDiagnostic, errors, warnings int) { + for _, d := range diagnostics { + if d.severity == "info" { + continue + } + fmt.Fprintf(opts.IO.Out, "%s\t%s\t%s\n", d.severity, d.skill, d.message) + } + if errors == 0 && warnings == 0 { + fmt.Fprintf(opts.IO.Out, "ok\n") + } +} + +// findGitHubMetadataKeys returns metadata keys with the "github-" prefix. +func findGitHubMetadataKeys(meta map[string]interface{}) []string { + var keys []string + for k := range meta { + if strings.HasPrefix(k, "github-") { + keys = append(keys, k) + } + } + sort.Strings(keys) + return keys +} + +// stripGitHubMetadata removes github-* keys from the metadata map and re-serializes. +func stripGitHubMetadata(content string) (string, error) { + result, err := frontmatter.Parse(content) + if err != nil { + return "", err + } + + meta, ok := result.RawYAML["metadata"].(map[string]interface{}) + if !ok { + return content, nil + } + + for k := range meta { + if strings.HasPrefix(k, "github-") { + delete(meta, k) + } + } + + if len(meta) == 0 { + delete(result.RawYAML, "metadata") + } else { + result.RawYAML["metadata"] = meta + } + + return frontmatter.Serialize(result.RawYAML, result.Body) +} + +// claudePluginJSON is the .claude-plugin/plugin.json structure. +type claudePluginJSON struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Version string `json:"version,omitempty"` + Author *claudeAuthor `json:"author,omitempty"` + Homepage string `json:"homepage,omitempty"` + Repository string `json:"repository,omitempty"` + License string `json:"license,omitempty"` + Keywords []string `json:"keywords,omitempty"` +} + +type claudeAuthor struct { + Name string `json:"name"` +} + +// claudeMarketplaceJSON is the .claude-plugin/marketplace.json structure. +type claudeMarketplaceJSON struct { + Name string `json:"name"` + Owner claudeAuthor `json:"owner"` + Plugins []claudeMarketplacePlugin `json:"plugins"` +} + +type claudeMarketplacePlugin struct { + Name string `json:"name"` + Source string `json:"source"` + Description string `json:"description,omitempty"` +} + +// generateClaudePlugin creates .claude-plugin/plugin.json (and optionally +// marketplace.json for multi-skill repos). +func generateClaudePlugin(dir string, skillDirs []string, owner, repo string) []publishDiagnostic { + var diags []publishDiagnostic + + pluginDir := filepath.Join(dir, ".claude-plugin") + pluginPath := filepath.Join(pluginDir, "plugin.json") + + // Don't overwrite existing plugin.json + if _, err := os.Stat(pluginPath); err == nil { + diags = append(diags, publishDiagnostic{ + severity: "info", + message: ".claude-plugin/plugin.json already exists (skipped)", + }) + return diags + } + + pluginName := filepath.Base(dir) + if repo != "" { + pluginName = repo + } + + description := buildPluginDescription(dir, skillDirs) + + plugin := claudePluginJSON{ + Name: pluginName, + Description: description, + Version: "1.0.0", + Keywords: []string{"agent-skills"}, + } + + if owner != "" && repo != "" { + plugin.Repository = fmt.Sprintf("https://github.com/%s/%s", owner, repo) + plugin.Homepage = fmt.Sprintf("https://github.com/%s/%s", owner, repo) + plugin.Author = &claudeAuthor{Name: owner} + } + + // Collect license from any skill + for _, skillName := range skillDirs { + skillPath := filepath.Join(dir, "skills", skillName, "SKILL.md") + content, err := os.ReadFile(skillPath) + if err != nil { + continue + } + result, err := frontmatter.Parse(string(content)) + if err != nil { + continue + } + if result.Metadata.License != "" { + plugin.License = result.Metadata.License + break + } + } + + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + diags = append(diags, publishDiagnostic{ + severity: "error", + message: fmt.Sprintf("could not create .claude-plugin/: %v", err), + }) + return diags + } + + data, err := json.MarshalIndent(plugin, "", " ") + if err != nil { + diags = append(diags, publishDiagnostic{ + severity: "error", + message: fmt.Sprintf("could not serialize plugin.json: %v", err), + }) + return diags + } + + if err := os.WriteFile(pluginPath, append(data, '\n'), 0o644); err != nil { + diags = append(diags, publishDiagnostic{ + severity: "error", + message: fmt.Sprintf("could not write plugin.json: %v", err), + }) + return diags + } + + diags = append(diags, publishDiagnostic{ + severity: "info", + message: fmt.Sprintf("generated .claude-plugin/plugin.json for %q with %d skill(s)", pluginName, len(skillDirs)), + }) + + // Generate marketplace.json for multi-skill repos with a GitHub remote + if len(skillDirs) > 1 && owner != "" && repo != "" { + marketplacePath := filepath.Join(pluginDir, "marketplace.json") + if _, err := os.Stat(marketplacePath); err != nil { + mDiags := generateMarketplace(marketplacePath, pluginName, owner, skillDirs, dir) + diags = append(diags, mDiags...) + } + } + + return diags +} + +// generateMarketplace creates a marketplace.json for plugin marketplace discovery. +func generateMarketplace(path, pluginName, owner string, skillDirs []string, dir string) []publishDiagnostic { + desc := buildPluginDescription(dir, skillDirs) + plugins := []claudeMarketplacePlugin{{ + Name: pluginName, + Source: ".", + Description: desc, + }} + + marketplace := claudeMarketplaceJSON{ + Name: pluginName, + Owner: claudeAuthor{Name: owner}, + Plugins: plugins, + } + + data, err := json.MarshalIndent(marketplace, "", " ") + if err != nil { + return []publishDiagnostic{{ + severity: "error", + message: fmt.Sprintf("could not serialize marketplace.json: %v", err), + }} + } + + if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { + return []publishDiagnostic{{ + severity: "error", + message: fmt.Sprintf("could not write marketplace.json: %v", err), + }} + } + + return []publishDiagnostic{{ + severity: "info", + message: "generated .claude-plugin/marketplace.json for plugin marketplace discovery", + }} +} + +// buildPluginDescription creates a description from skill names and descriptions. +func buildPluginDescription(dir string, skillDirs []string) string { + if len(skillDirs) == 1 { + skillPath := filepath.Join(dir, "skills", skillDirs[0], "SKILL.md") + if content, err := os.ReadFile(skillPath); err == nil { + if result, err := frontmatter.Parse(string(content)); err == nil && result.Metadata.Description != "" { + return result.Metadata.Description + } + } + } + + var names []string + for _, name := range skillDirs { + names = append(names, name) + } + if len(names) <= 5 { + return fmt.Sprintf("Agent skills: %s", strings.Join(names, ", ")) + } + return fmt.Sprintf("Agent skills collection with %d skills", len(names)) +} diff --git a/pkg/cmd/skills/publish/publish_test.go b/pkg/cmd/skills/publish/publish_test.go new file mode 100644 index 000000000..56e3b1e0a --- /dev/null +++ b/pkg/cmd/skills/publish/publish_test.go @@ -0,0 +1,1059 @@ +package publish + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/require" +) + +func testPublishGitClient(t *testing.T, remoteURLs map[string]string) *git.Client { + t.Helper() + dir := t.TempDir() + runGit := func(args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+dir) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, out) + } + runGit("init", "--initial-branch=main") + runGit("config", "user.email", "monalisa@github.com") + runGit("config", "user.name", "Monalisa Octocat") + for name, url := range remoteURLs { + runGit("remote", "add", name, url) + } + return &git.Client{RepoDir: dir} +} + +func TestPublishCmd_Help(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := stubFactory(ios) + cmd := NewCmdPublish(&f, nil) + if cmd.Use == "" { + t.Error("publish command has no Use string") + } + if cmd.Short == "" { + t.Error("publish command has no Short description") + } +} + +func TestPublishCmd_Alias(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := stubFactory(ios) + cmd := NewCmdPublish(&f, nil) + found := false + for _, alias := range cmd.Aliases { + if alias == "validate" { + found = true + break + } + } + if !found { + t.Error("publish command should have 'validate' alias") + } +} + +func TestPublish_ValidSkill(t *testing.T) { + dir := t.TempDir() + skillDir := filepath.Join(dir, "skills", "git-commit") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + + content := `--- +name: git-commit +description: A skill for writing good git commits +allowed-tools: git +license: MIT +--- +You are a git commit expert. +` + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + ios, _, stdout, _ := iostreams.Test() + + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/test/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"agent-skills"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/test/skills-repo/tags"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "v1.0.0"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/test/skills-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "tags", "target": "tag", "enforcement": "active"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/test/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + + opts := &publishOptions{ + IO: ios, + Dir: dir, + GitClient: testPublishGitClient(t, map[string]string{ + "origin": "https://github.com/test/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + + err := publishRun(opts) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "ok") { + t.Errorf("expected 'ok' output, got: %s", out) + } +} + +func TestPublish_MissingName(t *testing.T) { + dir := t.TempDir() + skillDir := filepath.Join(dir, "skills", "git-commit") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + + content := `--- +description: A skill for writing good git commits +--- +Body text. +` + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + ios, _, stdout, _ := iostreams.Test() + + opts := &publishOptions{ + IO: ios, + Dir: dir, + } + + err := publishRun(opts) + if err == nil { + t.Fatal("expected error for missing name") + } + + out := stdout.String() + if !strings.Contains(out, "missing required field: name") { + t.Errorf("expected name error in output, got: %s", out) + } +} + +func TestPublish_NameMismatch(t *testing.T) { + dir := t.TempDir() + skillDir := filepath.Join(dir, "skills", "git-commit") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + + content := `--- +name: wrong-name +description: A skill +--- +Body. +` + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + ios, _, stdout, _ := iostreams.Test() + + opts := &publishOptions{ + IO: ios, + Dir: dir, + } + + err := publishRun(opts) + if err == nil { + t.Fatal("expected error for name mismatch") + } + + out := stdout.String() + if !strings.Contains(out, "does not match directory name") { + t.Errorf("expected name mismatch error, got: %s", out) + } +} + +func TestPublish_NonSpecCompliantName(t *testing.T) { + dir := t.TempDir() + skillDir := filepath.Join(dir, "skills", "My_Skill") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + + content := `--- +name: My_Skill +description: A skill with non-compliant name +--- +Body. +` + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + ios, _, stdout, _ := iostreams.Test() + + opts := &publishOptions{ + IO: ios, + Dir: dir, + } + + err := publishRun(opts) + if err == nil { + t.Fatal("expected error for non-spec-compliant name") + } + + out := stdout.String() + if !strings.Contains(out, "naming convention") { + t.Errorf("expected naming convention error, got: %s", out) + } +} + +func TestPublish_AllowedToolsArray(t *testing.T) { + dir := t.TempDir() + skillDir := filepath.Join(dir, "skills", "bad-tools") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + + content := `--- +name: bad-tools +description: A skill with array allowed-tools +allowed-tools: + - git + - curl +--- +Body. +` + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + ios, _, stdout, _ := iostreams.Test() + + opts := &publishOptions{ + IO: ios, + Dir: dir, + } + + err := publishRun(opts) + if err == nil { + t.Fatal("expected error for array allowed-tools") + } + + out := stdout.String() + if !strings.Contains(out, "allowed-tools must be a string") { + t.Errorf("expected allowed-tools error, got: %s", out) + } +} + +func TestPublish_StripMetadata(t *testing.T) { + dir := t.TempDir() + skillDir := filepath.Join(dir, "skills", "test-skill") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + + content := `--- +name: test-skill +description: A test skill +metadata: + github-owner: someone + github-repo: something + github-ref: v1.0.0 + github-sha: abc123 + github-tree-sha: def456 +--- +Body. +` + skillPath := filepath.Join(skillDir, "SKILL.md") + if err := os.WriteFile(skillPath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + ios, _, _, _ := iostreams.Test() + + opts := &publishOptions{ + IO: ios, + Dir: dir, + Fix: true, + } + + err := publishRun(opts) + if err != nil { + t.Fatalf("expected no error with --fix, got: %v", err) + } + + fixed, err := os.ReadFile(skillPath) + if err != nil { + t.Fatal(err) + } + + fixedStr := string(fixed) + if strings.Contains(fixedStr, "github-owner") { + t.Errorf("expected github-owner to be stripped, got:\n%s", fixedStr) + } + if strings.Contains(fixedStr, "github-sha") { + t.Errorf("expected github-sha to be stripped, got:\n%s", fixedStr) + } + if strings.Contains(fixedStr, "metadata:") { + t.Errorf("expected empty metadata map to be removed, got:\n%s", fixedStr) + } +} + +func TestPublish_MetadataWithoutFix(t *testing.T) { + dir := t.TempDir() + skillDir := filepath.Join(dir, "skills", "test-skill") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + + content := `--- +name: test-skill +description: A test skill +metadata: + github-owner: someone + github-sha: abc123 +--- +Body. +` + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + ios, _, stdout, _ := iostreams.Test() + + opts := &publishOptions{ + IO: ios, + Dir: dir, + Fix: false, + } + + err := publishRun(opts) + if err == nil { + t.Fatal("expected error without --fix when metadata present") + } + + out := stdout.String() + if !strings.Contains(out, "install metadata") { + t.Errorf("expected install metadata error, got: %s", out) + } + if !strings.Contains(out, "--fix") { + t.Errorf("expected --fix suggestion, got: %s", out) + } +} + +func TestPublish_NoSkillsDir(t *testing.T) { + dir := t.TempDir() + ios, _, _, _ := iostreams.Test() + + opts := &publishOptions{ + IO: ios, + Dir: dir, + } + + err := publishRun(opts) + if err == nil { + t.Fatal("expected error for missing skills/ directory") + } + if !strings.Contains(err.Error(), "no skills/ directory") { + t.Errorf("expected 'no skills/ directory' error, got: %v", err) + } +} + +func TestPublish_MissingSKILLMD(t *testing.T) { + dir := t.TempDir() + skillDir := filepath.Join(dir, "skills", "empty-skill") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + + ios, _, stdout, _ := iostreams.Test() + + opts := &publishOptions{ + IO: ios, + Dir: dir, + } + + err := publishRun(opts) + if err == nil { + t.Fatal("expected error for missing SKILL.md") + } + + out := stdout.String() + if !strings.Contains(out, "missing SKILL.md") { + t.Errorf("expected missing SKILL.md error, got: %s", out) + } +} + +func TestPublish_DryRun(t *testing.T) { + dir := t.TempDir() + skillDir := filepath.Join(dir, "skills", "good-skill") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + + content := `--- +name: good-skill +description: A good skill +license: MIT +--- +Body. +` + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + ios, _, _, stderr := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/test/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"agent-skills"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/test/skills-repo/tags"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "v1.0.0"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/test/skills-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "tags", "target": "tag", "enforcement": "active"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/test/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + + opts := &publishOptions{ + IO: ios, + Dir: dir, + DryRun: true, + GitClient: testPublishGitClient(t, map[string]string{ + "origin": "https://github.com/test/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + + err := publishRun(opts) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + errOut := stderr.String() + if !strings.Contains(errOut, "Dry run complete") { + t.Errorf("stderr should confirm dry run, got: %s", errOut) + } +} + +func TestPublish_LicenseWarning(t *testing.T) { + dir := t.TempDir() + skillDir := filepath.Join(dir, "skills", "no-license") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + + content := `--- +name: no-license +description: A skill without license +--- +Body. +` + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + ios, _, stdout, _ := iostreams.Test() + + opts := &publishOptions{ + IO: ios, + Dir: dir, + } + + err := publishRun(opts) + if err != nil { + t.Fatalf("expected no error (warnings only), got: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "license") { + t.Errorf("expected license warning, got: %s", out) + } +} + +func TestSuggestNextTag(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"v1.0.0", "v1.0.1"}, + {"v2.3.4", "v2.3.5"}, + {"1.0.0", "1.0.1"}, + {"v0.0.9", "v0.0.10"}, + {"not-semver", ""}, + {"v1", ""}, + {"v1.0", ""}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := suggestNextTag(tt.input) + if got != tt.want { + t.Errorf("suggestNextTag(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestParseGitHubURL(t *testing.T) { + tests := []struct { + url string + wantOwner string + wantRepo string + }{ + {"git@github.com:github/gh-skills.git", "github", "gh-skills"}, + {"https://github.com/github/gh-skills.git", "github", "gh-skills"}, + {"https://github.com/github/gh-skills", "github", "gh-skills"}, + {"git@github.com:owner/repo.git", "owner", "repo"}, + {"https://gitlab.com/owner/repo.git", "", ""}, + {"not-a-url", "", ""}, + } + for _, tt := range tests { + t.Run(tt.url, func(t *testing.T) { + owner, repo := parseGitHubURL(tt.url) + if owner != tt.wantOwner || repo != tt.wantRepo { + t.Errorf("parseGitHubURL(%q) = (%q, %q), want (%q, %q)", tt.url, owner, repo, tt.wantOwner, tt.wantRepo) + } + }) + } +} + +func TestRepoHasTopic(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"golang", "agent-skills"}, + }), + ) + + if !repoHasTopic(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo") { + t.Error("expected true when topic present") + } +} + +func TestRepoHasTopic_Missing(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"golang"}, + }), + ) + + if repoHasTopic(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo") { + t.Error("expected false when topic missing") + } +} + +func TestFetchTags_NoTags(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/tags"), + httpmock.JSONResponse([]interface{}{}), + ) + + tags := fetchTags(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo") + if len(tags) != 0 { + t.Errorf("expected no tags, got %d", len(tags)) + } +} + +func TestFetchTags_WithTags(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/tags"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "v1.2.3"}, + }), + ) + + tags := fetchTags(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo") + if len(tags) != 1 { + t.Fatalf("expected 1 tag, got %d", len(tags)) + } + if tags[0].Name != "v1.2.3" { + t.Errorf("expected v1.2.3, got %s", tags[0].Name) + } +} + +func TestCheckTagProtection_Active(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "protect-tags", "target": "tag", "enforcement": "active"}, + }), + ) + + diags := checkTagProtection(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo") + if len(diags) != 0 { + t.Errorf("expected no diagnostics when tag protection active, got: %v", diags) + } +} + +func TestCheckTagProtection_Missing(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "branch-protection", "target": "branch", "enforcement": "active"}, + }), + ) + + diags := checkTagProtection(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo") + if len(diags) != 1 { + t.Fatalf("expected 1 diagnostic, got %d", len(diags)) + } + if !strings.Contains(diags[0].message, "tag protection") { + t.Errorf("expected tag protection warning, got: %s", diags[0].message) + } +} + +func TestCheckSecuritySettings_AllEnabled(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + + skillsDir := t.TempDir() + + diags := checkSecuritySettings(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo", skillsDir) + if len(diags) != 0 { + t.Errorf("expected no diagnostics when all security enabled, got %d: %v", len(diags), diags) + } +} + +func TestCheckSecuritySettings_NoneEnabled(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "disabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "disabled"}, + }, + }), + ) + + skillsDir := t.TempDir() + + diags := checkSecuritySettings(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo", skillsDir) + if len(diags) != 2 { + t.Errorf("expected 2 diagnostics (secret scanning + push protection), got %d: %v", len(diags), diags) + } + for _, d := range diags { + if d.severity != "warning" { + t.Errorf("secret scanning diagnostics should be warnings, got %q: %s", d.severity, d.message) + } + } +} + +func TestCheckSecuritySettings_WithCodeFiles(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/code-scanning/alerts"), + httpmock.StatusStringResponse(404, "not found"), + ) + + skillsDir := t.TempDir() + scriptDir := filepath.Join(skillsDir, "my-skill", "scripts") + if err := os.MkdirAll(scriptDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(scriptDir, "helper.sh"), []byte("#!/bin/bash"), 0o644); err != nil { + t.Fatal(err) + } + + diags := checkSecuritySettings(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo", skillsDir) + hasCodeScanInfo := false + for _, d := range diags { + if strings.Contains(d.message, "code scanning") { + hasCodeScanInfo = true + if d.severity != "info" { + t.Errorf("code scanning suggestion should be info, got %q", d.severity) + } + } + } + if !hasCodeScanInfo { + t.Error("expected code scanning info when code files present") + } +} + +func TestCheckSecuritySettings_WithManifests(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/vulnerability-alerts"), + httpmock.StatusStringResponse(404, "not found"), + ) + + skillsDir := t.TempDir() + skillDir := filepath.Join(skillsDir, "my-skill") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillDir, "package.json"), []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + + diags := checkSecuritySettings(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo", skillsDir) + hasDependabotInfo := false + for _, d := range diags { + if strings.Contains(d.message, "Dependabot") { + hasDependabotInfo = true + if d.severity != "info" { + t.Errorf("Dependabot suggestion should be info, got %q", d.severity) + } + } + } + if !hasDependabotInfo { + t.Error("expected Dependabot info when manifest files present") + } +} + +func TestDetectCodeAndManifests(t *testing.T) { + dir := t.TempDir() + + hasCode, hasManifests := detectCodeAndManifests(dir) + if hasCode || hasManifests { + t.Error("empty dir should have no code or manifests") + } + + if err := os.WriteFile(filepath.Join(dir, "run.sh"), []byte("#!/bin/bash"), 0o644); err != nil { + t.Fatal(err) + } + hasCode, hasManifests = detectCodeAndManifests(dir) + if !hasCode { + t.Error("should detect .sh as code") + } + if hasManifests { + t.Error("should not detect manifests") + } + + if err := os.WriteFile(filepath.Join(dir, "requirements.txt"), []byte("flask"), 0o644); err != nil { + t.Fatal(err) + } + hasCode, hasManifests = detectCodeAndManifests(dir) + if !hasCode || !hasManifests { + t.Error("should detect both code and manifests") + } +} + +func TestCheckInstalledSkillDirs_NotPresent(t *testing.T) { + dir := t.TempDir() + diags := checkInstalledSkillDirs(nil, dir) + if len(diags) != 0 { + t.Errorf("expected no diagnostics for empty dir, got %d", len(diags)) + } +} + +func TestCheckInstalledSkillDirs_PresentNotIgnored(t *testing.T) { + gitClient := testPublishGitClient(t, nil) + dir := gitClient.RepoDir + + installedDir := filepath.Join(dir, ".github", "skills", "some-skill") + if err := os.MkdirAll(installedDir, 0o755); err != nil { + t.Fatal(err) + } + + diags := checkInstalledSkillDirs(gitClient, dir) + if len(diags) == 0 { + t.Fatal("expected warning for unignored .github/skills/") + } + if diags[0].severity != "warning" { + t.Errorf("expected warning, got %q", diags[0].severity) + } + if !strings.Contains(diags[0].message, ".gitignore") { + t.Errorf("expected .gitignore mention, got: %s", diags[0].message) + } +} + +func TestCheckInstalledSkillDirs_PresentAndIgnored(t *testing.T) { + gitClient := testPublishGitClient(t, nil) + dir := gitClient.RepoDir + + installedDir := filepath.Join(dir, ".github", "skills", "some-skill") + if err := os.MkdirAll(installedDir, 0o755); err != nil { + t.Fatal(err) + } + + // Add .gitignore so git check-ignore recognises the path. + if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(".github/skills\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit := func(args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+dir) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, out) + } + runGit("add", ".gitignore") + runGit("commit", "-m", "init") + + diags := checkInstalledSkillDirs(gitClient, dir) + if len(diags) != 0 { + t.Errorf("expected no diagnostics when gitignored, got %d: %v", len(diags), diags) + } +} + +func TestGenerateClaudePlugin(t *testing.T) { + dir := t.TempDir() + + for _, name := range []string{"git-commit", "code-review"} { + skillDir := filepath.Join(dir, "skills", name) + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + content := fmt.Sprintf("---\nname: %s\ndescription: A %s skill\nlicense: MIT\n---\nBody.\n", name, name) + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + diags := generateClaudePlugin(dir, []string{"git-commit", "code-review"}, "testowner", "testrepo") + + var generated int + for _, d := range diags { + if d.severity == "error" { + t.Errorf("unexpected error: %s", d.message) + } + if d.severity == "info" && strings.Contains(d.message, "generated") { + generated++ + } + } + if generated != 2 { + t.Errorf("expected 2 generated files, got %d", generated) + } + + pluginData, err := os.ReadFile(filepath.Join(dir, ".claude-plugin", "plugin.json")) + if err != nil { + t.Fatalf("plugin.json not created: %v", err) + } + var plugin claudePluginJSON + if err := json.Unmarshal(pluginData, &plugin); err != nil { + t.Fatalf("invalid plugin.json: %v", err) + } + if plugin.Name != "testrepo" { + t.Errorf("plugin.Name = %q, want %q", plugin.Name, "testrepo") + } + if plugin.License != "MIT" { + t.Errorf("plugin.License = %q, want %q", plugin.License, "MIT") + } + if plugin.Repository != "https://github.com/testowner/testrepo" { + t.Errorf("plugin.Repository = %q", plugin.Repository) + } + + marketData, err := os.ReadFile(filepath.Join(dir, ".claude-plugin", "marketplace.json")) + if err != nil { + t.Fatalf("marketplace.json not created: %v", err) + } + var marketplace claudeMarketplaceJSON + if err := json.Unmarshal(marketData, &marketplace); err != nil { + t.Fatalf("invalid marketplace.json: %v", err) + } + if marketplace.Name != "testrepo" { + t.Errorf("marketplace.Name = %q, want %q", marketplace.Name, "testrepo") + } + if len(marketplace.Plugins) != 1 || marketplace.Plugins[0].Source != "." { + t.Errorf("marketplace.Plugins = %+v", marketplace.Plugins) + } +} + +func TestGenerateClaudePlugin_SkipsExisting(t *testing.T) { + dir := t.TempDir() + + skillDir := filepath.Join(dir, "skills", "my-skill") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: my-skill\ndescription: test\n---\nBody.\n"), 0o644); err != nil { + t.Fatal(err) + } + + pluginDir := filepath.Join(dir, ".claude-plugin") + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(`{"name":"existing"}`), 0o644); err != nil { + t.Fatal(err) + } + + diags := generateClaudePlugin(dir, []string{"my-skill"}, "owner", "repo") + + for _, d := range diags { + if d.severity == "error" { + t.Errorf("unexpected error: %s", d.message) + } + if strings.Contains(d.message, "generated") { + t.Error("should not regenerate existing plugin.json") + } + } +} + +func TestDetectGitHubRemote(t *testing.T) { + gitClient := testPublishGitClient(t, map[string]string{ + "origin": "https://github.com/myorg/myrepo.git", + }) + + owner, repo := detectGitHubRemote(gitClient) + if owner != "myorg" || repo != "myrepo" { + t.Errorf("expected myorg/myrepo, got %s/%s", owner, repo) + } +} + +func TestDetectGitHubRemote_Fallback(t *testing.T) { + gitClient := testPublishGitClient(t, map[string]string{ + "origin": "https://gitlab.com/foo/bar.git", + "upstream": "git@github.com:org/repo.git", + }) + + owner, repo := detectGitHubRemote(gitClient) + if owner != "org" || repo != "repo" { + t.Errorf("expected org/repo, got %s/%s", owner, repo) + } +} + +func TestDetectGitHubRemote_NoGitHub(t *testing.T) { + gitClient := testPublishGitClient(t, map[string]string{ + "origin": "https://gitlab.com/foo/bar.git", + }) + + owner, repo := detectGitHubRemote(gitClient) + if owner != "" || repo != "" { + t.Errorf("expected empty, got %s/%s", owner, repo) + } +} + +func TestPublishCmd_RunFHook(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := stubFactory(ios) + + var capturedOpts *publishOptions + cmd := NewCmdPublish(&f, func(opts *publishOptions) error { + capturedOpts = opts + return nil + }) + + cmd.SetArgs([]string{"./my-skills", "--dry-run", "--fix", "--tag", "v1.0.0"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if capturedOpts == nil { + t.Fatal("runF was not called") + } + if capturedOpts.Dir != "./my-skills" { + t.Errorf("Dir = %q, want %q", capturedOpts.Dir, "./my-skills") + } + if !capturedOpts.DryRun { + t.Error("expected DryRun to be true") + } + if !capturedOpts.Fix { + t.Error("expected Fix to be true") + } + if capturedOpts.Tag != "v1.0.0" { + t.Errorf("Tag = %q, want %q", capturedOpts.Tag, "v1.0.0") + } +} + +// stubFactory creates a minimal cmdutil.Factory for tests. +func stubFactory(ios *iostreams.IOStreams) cmdutil.Factory { + return cmdutil.Factory{ + IOStreams: ios, + } +} diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go new file mode 100644 index 000000000..0d7e39043 --- /dev/null +++ b/pkg/cmd/skills/search/search.go @@ -0,0 +1,873 @@ +package search + +import ( + "errors" + "fmt" + "math" + "net/http" + "net/url" + "os" + "os/exec" + "sort" + "strings" + "sync" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/internal/skills/frontmatter" + "github.com/cli/cli/v2/internal/skills/registry" + "github.com/cli/cli/v2/internal/tableprinter" + "github.com/cli/cli/v2/internal/text" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +const ( + defaultLimit = 15 + maxResults = 1000 // GitHub Code Search API hard limit + + // searchPageSize is the number of raw results to request from the + // GitHub Search API per call (max allowed). + searchPageSize = 100 +) + +// SkillSearchFields defines the set of fields available for --json output. +var SkillSearchFields = []string{ + "repo", + "skillName", + "description", + "stars", + "path", +} + +type searchOptions struct { + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + Config func() (gh.Config, error) + Prompter prompter.Prompter + Executable string // path to the current gh binary for install subprocess + Exporter cmdutil.Exporter + + // User inputs + Query string + Owner string // optional: scope results to a specific GitHub owner + Page int + Limit int +} + +// NewCmdSearch creates the "skills search" command. +func NewCmdSearch(f *cmdutil.Factory, runF func(*searchOptions) error) *cobra.Command { + opts := &searchOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + Prompter: f.Prompter, + Executable: f.Executable(), + } + + cmd := &cobra.Command{ + Use: "search ", + Short: "Search for skills across GitHub", + Long: heredoc.Doc(` + Search across all public GitHub repositories for skills matching a keyword. + + Uses the GitHub Code Search API to find SKILL.md files whose name or + description matches the query term. + + Results are ranked by relevance: skills whose name contains the query + term appear first. + + Use --owner to scope results to a specific GitHub user or organization. + + In interactive mode, you can select skills from the results to install + directly. + `), + Example: heredoc.Doc(` + # Search for skills related to terraform + $ gh skills search terraform + + # Search for skills from a specific owner + $ gh skills search terraform --owner hashicorp + + # View the second page of results + $ gh skills search terraform --page 2 + + # Limit results to 5 + $ gh skills search terraform --limit 5 + `), + Args: cmdutil.MinimumArgs(1, "cannot search: query argument required"), + RunE: func(c *cobra.Command, args []string) error { + opts.Query = strings.Join(args, " ") + + if len(strings.TrimSpace(opts.Query)) < 2 { + return cmdutil.FlagErrorf("search query must be at least 2 characters") + } + + if opts.Page < 1 { + return cmdutil.FlagErrorf("invalid page number: %d", opts.Page) + } + + if opts.Limit < 1 { + return cmdutil.FlagErrorf("invalid limit: %d", opts.Limit) + } + + opts.Owner = strings.TrimSpace(opts.Owner) + if opts.Owner != "" && !couldBeOwner(opts.Owner) { + return cmdutil.FlagErrorf("invalid owner %q: must be a valid GitHub username or organization", opts.Owner) + } + + if runF != nil { + return runF(opts) + } + return searchRun(opts) + }, + } + + cmd.Flags().IntVar(&opts.Page, "page", 1, "Page number of results to fetch") + cmd.Flags().IntVarP(&opts.Limit, "limit", "L", defaultLimit, "Maximum number of results per page") + cmd.Flags().StringVar(&opts.Owner, "owner", "", "Filter results to a specific GitHub user or organization") + cmdutil.AddJSONFlags(cmd, &opts.Exporter, SkillSearchFields) + + return cmd +} + +// codeSearchResult represents the GitHub Code Search API response. +type codeSearchResult struct { + TotalCount int `json:"total_count"` + IncompleteResults bool `json:"incomplete_results"` + Items []codeSearchItem `json:"items"` +} + +// codeSearchItem represents a single code search hit. +type codeSearchItem struct { + Name string `json:"name"` + Path string `json:"path"` + SHA string `json:"sha"` + Repository codeSearchRepository `json:"repository"` +} + +// codeSearchRepository is the repo info embedded in a code search hit. +type codeSearchRepository struct { + FullName string `json:"full_name"` +} + +// skillResult is a deduplicated search result. +type skillResult struct { + Repo string + Owner string // parsed from Repo + RepoName string // parsed from Repo + SkillName string + Description string + Path string // original file path (e.g. skills/terraform/SKILL.md) + BlobSHA string + Stars int // repository stargazer count +} + +// ExportData implements cmdutil.exportable for --json output. +func (s skillResult) ExportData(fields []string) map[string]interface{} { + data := map[string]interface{}{} + for _, f := range fields { + switch f { + case "repo": + data[f] = s.Repo + case "skillName": + data[f] = s.SkillName + case "description": + data[f] = s.Description + case "stars": + data[f] = s.Stars + case "path": + data[f] = s.Path + } + } + return data +} + +func searchRun(opts *searchOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + apiClient := api.NewClientFromHTTP(httpClient) + + cfg, err := opts.Config() + if err != nil { + return err + } + host, _ := cfg.Authentication().DefaultHost() + + opts.IO.StartProgressIndicatorWithLabel("Searching for skills") + + skills, err := searchByKeyword(apiClient, host, opts.Query, opts.Owner, opts.Page, opts.Limit) + if err != nil { + opts.IO.StopProgressIndicator() + return err + } + + if len(skills) == 0 { + opts.IO.StopProgressIndicator() + return noResults(opts, noResultsMessage(opts)) + } + + // Pre-rank before expensive enrichment, then truncate working set. + rankByRelevance(skills, opts.Query) + skills = truncateForProcessing(skills, opts.Page, opts.Limit) + + enrichSkills(apiClient, host, skills) + opts.IO.StopProgressIndicator() + + // Filter out noise and re-rank with enriched data (descriptions, stars). + skills = filterByRelevance(skills, opts.Query) + if len(skills) == 0 { + return noResults(opts, noResultsMessage(opts)) + } + rankByRelevance(skills, opts.Query) + + // Collapse duplicate skill names across repos, keeping up to 3 + // top-ranked instances of each. Prevents aggregator repos + // (which copy popular skills) from flooding results. + skills = deduplicateByName(skills) + + // Paginate to the requested page window. + var totalPages int + skills, totalPages = paginate(skills, opts.Page, opts.Limit) + if len(skills) == 0 { + msg := fmt.Sprintf("no skills found on page %d for query %q", opts.Page, opts.Query) + if opts.Owner != "" { + msg = fmt.Sprintf("no skills found on page %d for query %q from owner %q", opts.Page, opts.Query, opts.Owner) + } + return noResults(opts, msg) + } + + return renderResults(opts, skills, totalPages) +} + +// noResultsMessage returns an appropriate "no results" message. +func noResultsMessage(opts *searchOptions) string { + if opts.Owner != "" { + return fmt.Sprintf("no skills found matching %q from owner %q", opts.Query, opts.Owner) + } + return fmt.Sprintf("no skills found matching %q", opts.Query) +} + +// searchByKeyword runs parallel searches: content match, path match, owner +// match (for single-word queries), and (for multi-word queries) a hyphenated +// content match to catch skill names like "mcp-apps" when the user types +// "mcp apps". When owner is non-empty, all queries are scoped to that +// GitHub user/org via user: and the implicit owner search is skipped. +func searchByKeyword(client *api.Client, host, queryTerm, owner string, page, limit int) ([]skillResult, error) { + ownerScope := "" + if owner != "" { + ownerScope = " user:" + owner + } + + primaryQ := fmt.Sprintf("filename:SKILL.md %s%s", queryTerm, ownerScope) + pathTerm := strings.ReplaceAll(queryTerm, " ", "-") + pathQ := fmt.Sprintf("filename:SKILL.md path:%s%s", pathTerm, ownerScope) + + var ( + primaryItems []codeSearchItem + primaryErr error + pathResult *codeSearchResult + pathErr error + ownerResult *codeSearchResult + ownerErr error + hyphenResult *codeSearchResult + hyphenErr error + ) + + hasSpaces := strings.Contains(queryTerm, " ") + + var wg sync.WaitGroup + + wg.Add(1) + go func() { + defer wg.Done() + pathResult, pathErr = executeSearch(client, host, pathQ, 1, searchPageSize) + }() + + // When no explicit --owner is set and the query looks like it could be a + // GitHub username, fire an additional user: search to discover + // skills published by that org. Results compete on the same footing as + // everything else (no scoring boost). + if owner == "" && couldBeOwner(queryTerm) { + ownerQ := fmt.Sprintf("filename:SKILL.md user:%s", queryTerm) + wg.Add(1) + go func() { + defer wg.Done() + ownerResult, ownerErr = executeSearch(client, host, ownerQ, 1, searchPageSize) + }() + } + + // When the query has spaces (e.g. "mcp apps"), run an additional content + // search with the hyphenated form ("mcp-apps") so we don't miss skills + // whose names use hyphens as word separators. + if hasSpaces { + hyphenQ := fmt.Sprintf("filename:SKILL.md %s%s", pathTerm, ownerScope) + wg.Add(1) + go func() { + defer wg.Done() + hyphenResult, hyphenErr = executeSearch(client, host, hyphenQ, 1, searchPageSize) + }() + } + + // Primary content search runs on the main goroutine. + primaryItems, _, primaryErr = fetchPrimaryPages(client, host, primaryQ, page, limit) + wg.Wait() + + if primaryErr != nil { + return nil, primaryErr + } + + // Merge: path-matched → hyphen-matched → owner-matched → primary content. + var merged []codeSearchItem + + if pathErr == nil && pathResult != nil { + merged = append(merged, pathResult.Items...) + } + if hasSpaces && hyphenErr == nil && hyphenResult != nil { + merged = append(merged, hyphenResult.Items...) + } + if ownerErr == nil && ownerResult != nil { + merged = append(merged, ownerResult.Items...) + } + merged = append(merged, primaryItems...) + + return deduplicateResults(merged), nil +} + +// noResults returns an empty JSON array for exporters or a no-results error. +func noResults(opts *searchOptions, msg string) error { + if opts.Exporter != nil { + return opts.Exporter.Write(opts.IO, []skillResult{}) + } + return cmdutil.NewNoResultsError(msg) +} + +// truncateForProcessing caps the working set before expensive enrichment. +// Each skill in the working set triggers a blob fetch (description) and +// potentially a repo fetch (stars), so keeping this small matters for +// performance. Pre-ranking ensures the best candidates are at the top. +func truncateForProcessing(skills []skillResult, page, limit int) []skillResult { + maxToProcess := page * limit * 3 + if maxToProcess < limit*3 { + maxToProcess = limit * 3 + } + if len(skills) > maxToProcess { + return skills[:maxToProcess] + } + return skills +} + +// enrichSkills fetches descriptions and star counts concurrently. +func enrichSkills(client *api.Client, host string, skills []skillResult) { + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + fetchDescriptions(client, host, skills) + }() + go func() { + defer wg.Done() + fetchRepoStars(client, host, skills) + }() + wg.Wait() +} + +// paginate slices results to the requested page window. +func paginate(skills []skillResult, page, limit int) ([]skillResult, int) { + total := len(skills) + totalPages := (total + limit - 1) / limit + start := (page - 1) * limit + if start >= total { + return nil, totalPages + } + end := start + limit + if end > total { + end = total + } + return skills[start:end], totalPages +} + +// deduplicateByName caps the number of results with the same skill name. +// Since results are pre-sorted by relevance score, the first occurrences +// are the best instances. This prevents aggregator repos (which copy +// popular skills verbatim) from flooding results while still showing +// a few alternative sources. +func deduplicateByName(skills []skillResult) []skillResult { + const maxPerName = 3 + counts := make(map[string]int) + var result []skillResult + for _, s := range skills { + key := strings.ToLower(s.SkillName) + if counts[key] >= maxPerName { + continue + } + counts[key]++ + result = append(result, s) + } + return result +} + +// renderResults handles all output modes: JSON, interactive picker, or table. +func renderResults(opts *searchOptions, skills []skillResult, totalPages int) error { + if opts.Exporter != nil { + return opts.Exporter.Write(opts.IO, skills) + } + + cs := opts.IO.ColorScheme() + header := fmt.Sprintf("\n%s Showing %s matching %q", + cs.SuccessIcon(), + pluralize(len(skills), "skill"), + opts.Query, + ) + if totalPages > 1 { + header += fmt.Sprintf(" (page %d/%d)", opts.Page, totalPages) + } + + if opts.IO.CanPrompt() { + fmt.Fprintln(opts.IO.ErrOut, header) + if opts.Page < totalPages { + fmt.Fprintf(opts.IO.ErrOut, "Use --page %d for more results.\n", opts.Page+1) + } + return promptInstall(opts, skills) + } + + // Non-interactive mode: render table. + if opts.IO.IsStdoutTTY() { + fmt.Fprintln(opts.IO.Out, header) + fmt.Fprintln(opts.IO.Out) + } + + if err := renderTable(opts.IO, skills); err != nil { + return err + } + + if opts.IO.IsStdoutTTY() && opts.Page < totalPages { + fmt.Fprintf(opts.IO.ErrOut, "\nUse --page %d for more results.\n", opts.Page+1) + } + + return nil +} + +// renderTable outputs a formatted table of skill results. +func renderTable(io *iostreams.IOStreams, skills []skillResult) error { + isTTY := io.IsStdoutTTY() + tw := io.TerminalWidth() + descWidth := tw - 70 + if descWidth < 20 { + descWidth = 20 + } + + table := tableprinter.New(io, tableprinter.WithHeader("REPOSITORY", "SKILL", "DESCRIPTION", "STARS")) + for _, s := range skills { + table.AddField(s.Repo) + table.AddField(s.SkillName) + desc := s.Description + if isTTY { + desc = text.Truncate(descWidth, desc) + } + table.AddField(desc) + table.AddField(formatStars(s.Stars)) + table.EndRow() + } + return table.Render() +} + +// promptInstall shows a multi-select picker for the user to choose skills +// to install from the search results, then runs the install command for each. +func promptInstall(opts *searchOptions, skills []skillResult) error { + fmt.Fprintln(opts.IO.ErrOut) + + cs := opts.IO.ColorScheme() + + // Reserve space for the checkbox UI prefix ("[ ] ") and the description + // indent ("\n " = 7 chars), then use the remaining terminal width. + tw := opts.IO.TerminalWidth() + descWidth := tw - 11 + if descWidth < 30 { + descWidth = 30 + } + + options := make([]string, len(skills)) + for i, s := range skills { + starStr := "" + if s.Stars > 0 { + starStr = " " + cs.Gray("★ "+formatStars(s.Stars)) + } + descStr := "" + if s.Description != "" { + desc := collapseWhitespace(s.Description) + descStr = "\n " + cs.Gray(text.Truncate(descWidth, desc)) + } + options[i] = s.SkillName + " " + cs.Gray(s.Repo) + starStr + descStr + } + + indices, err := opts.Prompter.MultiSelect( + "Select skills to install (press Enter to skip):", + nil, + options, + ) + if err != nil { + return err + } + + if len(indices) == 0 { + return nil + } + + // Prompt for target agent host (once for all selected skills) + hostNames := registry.AgentNames() + hostIdx, err := opts.Prompter.Select("Select target agent:", "", hostNames) + if err != nil { + return err + } + host := registry.Agents[hostIdx] + + // Prompt for installation scope + scopeIdx, err := opts.Prompter.Select("Installation scope:", "", registry.ScopeLabels("")) + if err != nil { + return err + } + scope := string(registry.ScopeProject) + if scopeIdx == 1 { + scope = string(registry.ScopeUser) + } + + for _, idx := range indices { + s := skills[idx] + fmt.Fprintf(opts.IO.ErrOut, "\n%s Installing %s from %s...\n", + cs.Blue("::"), s.SkillName, s.Repo) + + //nolint:gosec // arguments are from user-selected search results, not arbitrary input + cmd := exec.Command(opts.Executable, "skills", "install", s.Repo, s.SkillName, + "--agent", host.ID, "--scope", scope) + cmd.Stdin = os.Stdin + cmd.Stdout = opts.IO.Out + cmd.Stderr = opts.IO.ErrOut + if err := cmd.Run(); err != nil { + fmt.Fprintf(opts.IO.ErrOut, "%s Failed to install %s from %s: %s\n", + cs.Red("!"), s.SkillName, s.Repo, err) + } + } + + return nil +} + +// relevanceScore computes a numeric ranking score for a search result. +// Higher scores rank first. Signals (in priority order): +// - Exact skill name match (10 000 points) +// - Partial skill name match (1 000 points) +// - Description contains query (100 points) +// - Repository stars (logarithmic bonus, up to ~700 points) +func relevanceScore(s skillResult, query string) int { + term := strings.ToLower(query) + termHyphen := strings.ReplaceAll(term, " ", "-") + score := 0 + + // Name match. Normalize spaces to hyphens since skill directory names + // use hyphens as word separators (e.g. query "mcp apps" → "mcp-apps"). + skillLower := strings.ToLower(s.SkillName) + if skillLower == term || skillLower == termHyphen { + score += 10_000 + } else if strings.Contains(skillLower, term) || strings.Contains(skillLower, termHyphen) { + score += 1_000 + } + + // Description match. + if strings.Contains(strings.ToLower(s.Description), term) { + score += 100 + } + + // Stars bonus: use log₁₀ scaling so popular repos rank higher without + // completely drowning out less-popular but more relevant results. + if s.Stars > 0 { + score += int(math.Log10(float64(s.Stars)) * 150) + } + + return score +} + +// filterByRelevance removes results that are not meaningfully related to +// the query. A result is kept if the query term appears in the skill name, +// the YAML description, or the repository owner or name. +func filterByRelevance(skills []skillResult, query string) []skillResult { + queryTerm := strings.ToLower(query) + termHyphen := strings.ReplaceAll(queryTerm, " ", "-") + + filtered := skills[:0] // reuse backing array + for _, s := range skills { + nameLower := strings.ToLower(s.SkillName) + descLower := strings.ToLower(s.Description) + ownerLower := strings.ToLower(s.Owner) + repoLower := strings.ToLower(s.RepoName) + + if strings.Contains(nameLower, queryTerm) || + strings.Contains(nameLower, termHyphen) || + strings.Contains(descLower, queryTerm) || + strings.Contains(ownerLower, queryTerm) || + strings.Contains(repoLower, queryTerm) { + filtered = append(filtered, s) + } + } + return filtered +} + +// rankByRelevance sorts results by multi-signal score, highest first. +func rankByRelevance(skills []skillResult, query string) { + sort.SliceStable(skills, func(i, j int) bool { + return relevanceScore(skills[i], query) > relevanceScore(skills[j], query) + }) +} + +// couldBeOwner returns true if s looks like a valid GitHub username/org. +// GitHub usernames: 1-39 chars, alphanumeric or hyphen, no leading/trailing hyphens. +func couldBeOwner(s string) bool { + if len(s) == 0 || len(s) > 39 { + return false + } + for i, c := range s { + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9': + continue + case c == '-': + if i == 0 || i == len(s)-1 { + return false + } + default: + return false + } + } + return true +} + +// isRateLimitError checks whether err is a GitHub API rate-limit response. +// Per GitHub docs, a rate limit is indicated by: +// - HTTP 429 (always a rate limit) +// - HTTP 403 with x-ratelimit-remaining: 0 (primary rate limit) +// - HTTP 403 with a retry-after header (secondary rate limit) +func isRateLimitError(err error) bool { + var httpErr api.HTTPError + if !errors.As(err, &httpErr) { + return false + } + if httpErr.StatusCode == 429 { + return true + } + if httpErr.StatusCode == 403 { + if httpErr.Headers.Get("x-ratelimit-remaining") == "0" { + return true + } + if httpErr.Headers.Get("retry-after") != "" { + return true + } + } + return false +} + +// rateLimitErrorMessage returns a user-friendly message for rate-limit errors. +const rateLimitErrorMessage = "GitHub API rate limit exceeded. Please wait a minute and try again." + +// executeSearch performs a single GitHub Code Search API call. +func executeSearch(client *api.Client, host, query string, page, pageSize int) (*codeSearchResult, error) { + apiPath := fmt.Sprintf("search/code?q=%s&per_page=%d&page=%d", + url.QueryEscape(query), pageSize, page) + var result codeSearchResult + err := client.REST(host, "GET", apiPath, nil, &result) + if err != nil && isRateLimitError(err) { + return nil, fmt.Errorf("%s", rateLimitErrorMessage) + } + return &result, err +} + +// fetchPrimaryPages fetches enough API pages from GitHub Code Search to +// cover the requested display page, accounting for filtering losses. +func fetchPrimaryPages(client *api.Client, host, query string, displayPage, displayLimit int) ([]codeSearchItem, int, error) { + // Over-fetch to account for deduplication + filtering losses. + // The Code Search API is rate-limited at 10 req/min, so we keep + // page fetching conservative. Two pages (200 results) provides a + // good buffer for typical filter rates while staying well within + // the rate-limit budget. + needed := displayPage * displayLimit * 3 + numPages := (needed + searchPageSize - 1) / searchPageSize + if numPages < 1 { + numPages = 1 + } + maxAPIPages := maxResults / searchPageSize + if numPages > maxAPIPages { + numPages = maxAPIPages + } + + var allItems []codeSearchItem + var totalCount int + for p := 1; p <= numPages; p++ { + result, err := executeSearch(client, host, query, p, searchPageSize) + if err != nil { + if p == 1 { + return nil, 0, err + } + break // partial results from earlier pages are OK + } + allItems = append(allItems, result.Items...) + totalCount = result.TotalCount + if len(result.Items) < searchPageSize { + break // no more results available + } + } + return allItems, totalCount, nil +} + +// deduplicateResults extracts unique (repo, skill name) pairs from code search hits. +func deduplicateResults(items []codeSearchItem) []skillResult { + seen := make(map[string]struct{}) + var results []skillResult + + for _, item := range items { + skillName := extractSkillName(item.Path) + if skillName == "" { + continue + } + key := item.Repository.FullName + "/" + skillName + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + + owner, repoName := splitRepo(item.Repository.FullName) + results = append(results, skillResult{ + Repo: item.Repository.FullName, + Owner: owner, + RepoName: repoName, + SkillName: skillName, + Path: item.Path, + BlobSHA: item.SHA, + }) + } + + return results +} + +// splitRepo splits "owner/repo" into its components. +func splitRepo(fullName string) (string, string) { + parts := strings.SplitN(fullName, "/", 2) + if len(parts) != 2 { + return fullName, "" + } + return parts[0], parts[1] +} + +// fetchDescriptions fetches SKILL.md frontmatter descriptions concurrently +// for all search results. Each result may come from a different repo. +func fetchDescriptions(client *api.Client, host string, skills []skillResult) { + const maxWorkers = 10 + sem := make(chan struct{}, maxWorkers) + var wg sync.WaitGroup + var mu sync.Mutex + + for i := range skills { + if skills[i].BlobSHA == "" { + continue + } + wg.Add(1) + go func(idx int) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + content, err := discovery.FetchBlob(client, host, skills[idx].Owner, skills[idx].RepoName, skills[idx].BlobSHA) + if err != nil { + return + } + result, err := frontmatter.Parse(content) + if err != nil { + return + } + + mu.Lock() + skills[idx].Description = result.Metadata.Description + mu.Unlock() + }(i) + } + wg.Wait() +} + +// extractSkillName derives the skill name from a SKILL.md path, but only if +// the path matches a known skill convention (skills/*, skills/scope/*, root-level, +// or plugins/*/skills/*). Returns empty string for non-conforming paths. +func extractSkillName(filePath string) string { + return discovery.MatchesSkillPath(filePath) +} + +func pluralize(count int, singular string) string { + if count == 1 { + return fmt.Sprintf("%d %s", count, singular) + } + return fmt.Sprintf("%d %ss", count, singular) +} + +// collapseWhitespace replaces runs of whitespace (newlines, tabs, etc.) +// with a single space. +func collapseWhitespace(s string) string { + fields := strings.Fields(s) + return strings.Join(fields, " ") +} + +// formatStars formats a star count for display (e.g. 1700 → "1.7k"). +func formatStars(n int) string { + if n >= 1000 { + return fmt.Sprintf("%.1fk", float64(n)/1000) + } + return fmt.Sprintf("%d", n) +} + +// repoInfo holds the subset of repository metadata we fetch for ranking. +type repoInfo struct { + StargazersCount int `json:"stargazers_count"` +} + +// fetchRepoStars fetches stargazer counts for each unique repository in +// the result set, using bounded concurrency. +func fetchRepoStars(client *api.Client, host string, skills []skillResult) { + const maxWorkers = 10 + sem := make(chan struct{}, maxWorkers) + var wg sync.WaitGroup + var mu sync.Mutex + + repoStars := make(map[string]int) + seen := make(map[string]bool) + + for _, s := range skills { + if seen[s.Repo] { + continue + } + seen[s.Repo] = true + + wg.Add(1) + go func(owner, repo, fullName string) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + apiPath := fmt.Sprintf("repos/%s/%s", owner, repo) + var info repoInfo + if err := client.REST(host, "GET", apiPath, nil, &info); err != nil { + return + } + mu.Lock() + repoStars[fullName] = info.StargazersCount + mu.Unlock() + }(s.Owner, s.RepoName, s.Repo) + } + wg.Wait() + + for i := range skills { + if stars, ok := repoStars[skills[i].Repo]; ok { + skills[i].Stars = stars + } + } +} diff --git a/pkg/cmd/skills/search/search_test.go b/pkg/cmd/skills/search/search_test.go new file mode 100644 index 000000000..db266f460 --- /dev/null +++ b/pkg/cmd/skills/search/search_test.go @@ -0,0 +1,423 @@ +package search + +import ( + "net/http" + "testing" + + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCmdSearch(t *testing.T) { + tests := []struct { + name string + args string + wantOpts searchOptions + wantErr string + }{ + { + name: "query argument", + args: "terraform", + wantOpts: searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + }, + { + name: "with page flag", + args: "terraform --page 3", + wantOpts: searchOptions{Query: "terraform", Page: 3, Limit: defaultLimit}, + }, + { + name: "with limit flag", + args: "terraform --limit 5", + wantOpts: searchOptions{Query: "terraform", Page: 1, Limit: 5}, + }, + { + name: "with limit short flag", + args: "terraform -L 10", + wantOpts: searchOptions{Query: "terraform", Page: 1, Limit: 10}, + }, + { + name: "with owner flag", + args: "terraform --owner hashicorp", + wantOpts: searchOptions{Query: "terraform", Owner: "hashicorp", Page: 1, Limit: defaultLimit}, + }, + { + name: "no arguments", + args: "", + wantErr: "cannot search: query argument required", + }, + { + name: "invalid page", + args: "terraform --page 0", + wantErr: "invalid page number: 0", + }, + { + name: "query too short", + args: "a", + wantErr: "search query must be at least 2 characters", + }, + { + name: "query too short single char", + args: "x", + wantErr: "search query must be at least 2 characters", + }, + { + name: "invalid limit zero", + args: "terraform --limit 0", + wantErr: "invalid limit: 0", + }, + { + name: "invalid limit negative", + args: "terraform --limit -1", + wantErr: "invalid limit: -1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := &cmdutil.Factory{} + var gotOpts *searchOptions + cmd := NewCmdSearch(f, func(opts *searchOptions) error { + gotOpts = opts + return nil + }) + + argv := []string{} + if tt.args != "" { + for _, part := range splitOnSpaces(tt.args) { + if part != "" { + argv = append(argv, part) + } + } + } + cmd.SetArgs(argv) + cmd.SetOut(&discardWriter{}) + cmd.SetErr(&discardWriter{}) + + _, err := cmd.ExecuteC() + if tt.wantErr != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantOpts.Query, gotOpts.Query) + assert.Equal(t, tt.wantOpts.Owner, gotOpts.Owner) + assert.Equal(t, tt.wantOpts.Page, gotOpts.Page) + assert.Equal(t, tt.wantOpts.Limit, gotOpts.Limit) + }) + } +} + +func TestSearchRun(t *testing.T) { + const emptyCodeResponse = `{"total_count": 0, "incomplete_results": false, "items": []}` + + // stubKeywordSearch registers the HTTP stubs needed for a keyword search. + // searchByKeyword fires up to 3 concurrent search/code requests (path, + // owner, primary). Stubs are one-shot in httpmock, so we register one + // per request. + stubKeywordSearch := func(reg *httpmock.Registry, codeResponse string) { + for range 3 { + reg.Register( + httpmock.REST("GET", "search/code"), + httpmock.StringResponse(codeResponse), + ) + } + } + + tests := []struct { + name string + opts *searchOptions + tty bool + httpStubs func(*httpmock.Registry) + wantStdout string + wantStderr string + wantErr string + }{ + { + name: "displays results in non-TTY", + tty: false, + opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + stubKeywordSearch(reg, `{"total_count": 1, "incomplete_results": false, "items": [{"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "repository": {"full_name": "github/awesome-skills"}}]}`) + }, + wantStdout: "github/awesome-skills\tterraform\t\t0\n", + }, + { + name: "deduplicates results", + tty: false, + opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + stubKeywordSearch(reg, `{"total_count": 3, "incomplete_results": false, "items": [{"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "repository": {"full_name": "github/awesome-skills"}}, {"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "repository": {"full_name": "github/awesome-skills"}}, {"name": "SKILL.md", "path": "skills/terraform-aws/SKILL.md", "repository": {"full_name": "github/awesome-skills"}}]}`) + }, + wantStdout: "github/awesome-skills\tterraform\t\t0\ngithub/awesome-skills\tterraform-aws\t\t0\n", + }, + { + name: "no results", + tty: true, + opts: &searchOptions{Query: "nonexistent", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + stubKeywordSearch(reg, emptyCodeResponse) + }, + wantErr: `no skills found matching "nonexistent"`, + }, + { + name: "nested skill path", + tty: false, + opts: &searchOptions{Query: "my-skill", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + stubKeywordSearch(reg, `{"total_count": 1, "incomplete_results": false, "items": [{"name": "SKILL.md", "path": "skills/author/my-skill/SKILL.md", "repository": {"full_name": "org/repo"}}]}`) + }, + wantStdout: "org/repo\tmy-skill\t\t0\n", + }, + { + name: "ranks name-matching results first", + tty: false, + opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + stubKeywordSearch(reg, `{"total_count": 3, "incomplete_results": false, "items": [ + {"name": "SKILL.md", "path": "skills/terraform-deploy/SKILL.md", "repository": {"full_name": "org/repo1"}}, + {"name": "SKILL.md", "path": "skills/terraform-plan/SKILL.md", "repository": {"full_name": "org/repo2"}}, + {"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "repository": {"full_name": "org/repo3"}} + ]}`) + }, + // exact name match "terraform" first, then partial matches alphabetically by score + wantStdout: "org/repo3\tterraform\t\t0\norg/repo1\tterraform-deploy\t\t0\norg/repo2\tterraform-plan\t\t0\n", + }, + { + name: "caps total pages at 1000-result limit", + tty: false, + opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + stubKeywordSearch(reg, `{"total_count": 5000, "incomplete_results": false, "items": [{"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "repository": {"full_name": "org/repo"}}]}`) + }, + // In non-TTY mode, no header or pagination text is shown + wantStdout: "org/repo\tterraform\t\t0\n", + }, + { + name: "page beyond available results", + tty: false, + opts: &searchOptions{Query: "terraform", Page: 999, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + stubKeywordSearch(reg, `{"total_count": 1, "incomplete_results": false, "items": [{"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "repository": {"full_name": "org/repo"}}]}`) + }, + wantErr: `no skills found on page 999 for query "terraform"`, + }, + { + name: "json output with selected fields", + tty: false, + opts: func() *searchOptions { + exporter := cmdutil.NewJSONExporter() + exporter.SetFields([]string{"repo", "skillName", "stars"}) + return &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit, Exporter: exporter} + }(), + httpStubs: func(reg *httpmock.Registry) { + stubKeywordSearch(reg, `{"total_count": 1, "incomplete_results": false, "items": [{"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "repository": {"full_name": "github/awesome-skills"}}]}`) + }, + wantStdout: "[{\"repo\":\"github/awesome-skills\",\"skillName\":\"terraform\",\"stars\":0}]\n", + }, + { + name: "json output empty results", + tty: false, + opts: func() *searchOptions { + exporter := cmdutil.NewJSONExporter() + exporter.SetFields([]string{"repo", "skillName"}) + return &searchOptions{Query: "nonexistent", Page: 1, Limit: defaultLimit, Exporter: exporter} + }(), + httpStubs: func(reg *httpmock.Registry) { + stubKeywordSearch(reg, emptyCodeResponse) + }, + wantStdout: "[]\n", + }, + { + name: "rate limit error returns friendly message", + tty: false, + opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + // All search/code calls return 403 with x-ratelimit-remaining: 0 + for range 3 { + reg.Register( + httpmock.REST("GET", "search/code"), + httpmock.WithHeader( + httpmock.StatusJSONResponse(403, map[string]string{"message": "API rate limit exceeded"}), + "x-ratelimit-remaining", "0", + ), + ) + } + }, + wantErr: rateLimitErrorMessage, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + tt.opts.Config = func() (gh.Config, error) { + return config.NewBlankConfig(), nil + } + + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(tt.tty) + ios.SetStderrTTY(tt.tty) + tt.opts.IO = ios + + defer reg.Verify(t) + err := searchRun(tt.opts) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantStdout, stdout.String()) + assert.Equal(t, tt.wantStderr, stderr.String()) + }) + } +} + +func TestDeduplicateResults(t *testing.T) { + items := []codeSearchItem{ + {Path: "skills/terraform/SKILL.md", Repository: codeSearchRepository{FullName: "org/repo"}}, + {Path: "skills/terraform/SKILL.md", Repository: codeSearchRepository{FullName: "org/repo"}}, + {Path: "skills/docker/SKILL.md", Repository: codeSearchRepository{FullName: "org/repo"}}, + {Path: "skills/terraform/SKILL.md", Repository: codeSearchRepository{FullName: "other/repo"}}, + } + + results := deduplicateResults(items) + + assert.Equal(t, 3, len(results)) + assert.Equal(t, "org/repo", results[0].Repo) + assert.Equal(t, "org", results[0].Owner) + assert.Equal(t, "repo", results[0].RepoName) + assert.Equal(t, "terraform", results[0].SkillName) + assert.Equal(t, "docker", results[1].SkillName) + assert.Equal(t, "other/repo", results[2].Repo) + assert.Equal(t, "other", results[2].Owner) + assert.Equal(t, "terraform", results[2].SkillName) +} + +func TestExtractSkillName(t *testing.T) { + tests := []struct { + path string + want string + }{ + {"skills/terraform/SKILL.md", "terraform"}, + {"skills/author/my-skill/SKILL.md", "my-skill"}, + {"SKILL.md", ""}, + {"skills/docker/SKILL.md", "docker"}, + // Root-level convention + {"my-skill/SKILL.md", "my-skill"}, + // Plugins convention + {"plugins/openai/skills/chat/SKILL.md", "chat"}, + // Non-matching paths should be filtered out + {"random/nested/deep/SKILL.md", ""}, + {".hidden/SKILL.md", ""}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + got := extractSkillName(tt.path) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFilterByRelevance(t *testing.T) { + skills := []skillResult{ + {Repo: "org/repo1", Owner: "org", RepoName: "repo1", SkillName: "terraform"}, + {Repo: "org/repo2", Owner: "org", RepoName: "repo2", SkillName: "docker"}, + {Repo: "terraform-corp/tools", Owner: "terraform-corp", RepoName: "tools", SkillName: "linter"}, + {Repo: "acme/terraform-tools", Owner: "acme", RepoName: "terraform-tools", SkillName: "validator"}, + {Repo: "x/y", Owner: "x", RepoName: "y", SkillName: "unrelated", Description: "terraform integration"}, + {Repo: "x/z", Owner: "x", RepoName: "z", SkillName: "noise"}, + } + + filtered := filterByRelevance(skills, "terraform") + + // Should keep: name match (terraform), owner match (terraform-corp), + // repo name match (terraform-tools), description match (terraform integration). + // Should drop: docker, noise. + assert.Equal(t, 4, len(filtered)) + assert.Equal(t, "terraform", filtered[0].SkillName) + assert.Equal(t, "linter", filtered[1].SkillName) + assert.Equal(t, "validator", filtered[2].SkillName) + assert.Equal(t, "unrelated", filtered[3].SkillName) +} + +func TestRankByRelevance(t *testing.T) { + skills := []skillResult{ + {Repo: "org/repo1", Owner: "org", SkillName: "devops"}, + {Repo: "org/repo2", Owner: "org", SkillName: "terraform-plan"}, + {Repo: "org/repo3", Owner: "org", SkillName: "docker", Description: "Manages terraform docker containers"}, + {Repo: "org/repo4", Owner: "org", SkillName: "terraform"}, + } + + rankByRelevance(skills, "terraform") + + // Exact name match scores highest (10 000), then partial name (1 000), + // then description match (100), then body-only (0). + assert.Equal(t, "terraform", skills[0].SkillName) + assert.Equal(t, "terraform-plan", skills[1].SkillName) + assert.Equal(t, "docker", skills[2].SkillName) + assert.Equal(t, "devops", skills[3].SkillName) +} + +func TestRankByRelevanceStarsTiebreak(t *testing.T) { + skills := []skillResult{ + {Repo: "small/repo", Owner: "small", SkillName: "terraform", Stars: 10}, + {Repo: "big/repo", Owner: "big", SkillName: "terraform", Stars: 5000}, + } + + rankByRelevance(skills, "terraform") + + // Both have exact name match; big/repo wins on stars tiebreak + assert.Equal(t, "big/repo", skills[0].Repo) + assert.Equal(t, "small/repo", skills[1].Repo) +} + +func TestFormatStars(t *testing.T) { + assert.Equal(t, "0", formatStars(0)) + assert.Equal(t, "42", formatStars(42)) + assert.Equal(t, "999", formatStars(999)) + assert.Equal(t, "1.0k", formatStars(1000)) + assert.Equal(t, "1.7k", formatStars(1700)) + assert.Equal(t, "12.5k", formatStars(12500)) +} + +func splitOnSpaces(s string) []string { + var parts []string + current := "" + for _, c := range s { + if c == ' ' { + if current != "" { + parts = append(parts, current) + current = "" + } + } else { + current += string(c) + } + } + if current != "" { + parts = append(parts, current) + } + return parts +} + +type discardWriter struct{} + +func (d *discardWriter) Write(p []byte) (n int, err error) { + return len(p), nil +} diff --git a/pkg/cmd/skills/skills.go b/pkg/cmd/skills/skills.go index 61afc12a4..8a1367314 100644 --- a/pkg/cmd/skills/skills.go +++ b/pkg/cmd/skills/skills.go @@ -2,6 +2,10 @@ package skills import ( "github.com/cli/cli/v2/pkg/cmd/skills/install" + "github.com/cli/cli/v2/pkg/cmd/skills/preview" + "github.com/cli/cli/v2/pkg/cmd/skills/publish" + "github.com/cli/cli/v2/pkg/cmd/skills/search" + "github.com/cli/cli/v2/pkg/cmd/skills/update" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -16,6 +20,10 @@ func NewCmdSkills(f *cmdutil.Factory) *cobra.Command { } cmd.AddCommand(install.NewCmdInstall(f, nil)) + cmd.AddCommand(preview.NewCmdPreview(f, nil)) + cmd.AddCommand(publish.NewCmdPublish(f, nil)) + cmd.AddCommand(search.NewCmdSearch(f, nil)) + cmd.AddCommand(update.NewCmdUpdate(f, nil)) return cmd } diff --git a/pkg/cmd/skills/update/update.go b/pkg/cmd/skills/update/update.go new file mode 100644 index 000000000..42995a315 --- /dev/null +++ b/pkg/cmd/skills/update/update.go @@ -0,0 +1,560 @@ +package update + +import ( + "context" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/internal/skills/frontmatter" + "github.com/cli/cli/v2/internal/skills/installer" + "github.com/cli/cli/v2/internal/skills/registry" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +// updateOptions holds all dependencies and user-provided flags for the update command. +type updateOptions struct { + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + Config func() (gh.Config, error) + Prompter prompter.Prompter + GitClient *git.Client + + // Arguments + Skills []string // optional: specific skills to update + + // Flags + All bool // --all flag (update without prompting) + Force bool // --force flag (re-download even if SHAs match) + DryRun bool // --dry-run flag (report only, no changes) + Dir string // --dir flag (scan a custom directory) +} + +// installedSkill represents a locally installed skill parsed from its SKILL.md frontmatter. +type installedSkill struct { + name string + owner string + repo string + treeSHA string // tree SHA at install time + pinned string // explicit pin value (empty = unpinned) + sourcePath string // original path in source repo (e.g. "skills/author/name") + dir string // local directory path + host *registry.AgentHost + scope registry.Scope +} + +// pendingUpdate describes a single skill that has an available update. +type pendingUpdate struct { + local installedSkill + newSHA string // new tree SHA from remote + resolved *discovery.ResolvedRef + skill discovery.Skill +} + +// NewCmdUpdate creates the "skills update" command. +func NewCmdUpdate(f *cmdutil.Factory, runF func(*updateOptions) error) *cobra.Command { + opts := &updateOptions{ + IO: f.IOStreams, + Prompter: f.Prompter, + Config: f.Config, + GitClient: f.GitClient, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "update [...]", + Short: "Update installed skills to their latest versions", + Long: heredoc.Doc(` + Checks installed skills for available updates by comparing the local + tree SHA (from SKILL.md frontmatter) against the remote repository. + + Scans all known agent host directories (Copilot, Claude, Cursor, Codex, + Gemini, Antigravity) in both project and user scope automatically. + + Without arguments, checks all installed skills. With skill names, + checks only those specific skills. + + Pinned skills (installed with --pin) are skipped with a notice. + Use "gh skills install --pin " to change the pinned version. + + Skills without GitHub metadata (e.g. installed manually or by another + tool) are prompted for their source repository in interactive mode. + The update re-downloads the skill with metadata injected, so future + updates work automatically. + + With --force, re-downloads skills even when the remote version matches + the local tree SHA. This overwrites locally modified skill files with + their original content, but does not remove extra files added locally. + + In interactive mode, shows which skills have updates and asks for + confirmation before proceeding. With --all, updates without prompting. + With --dry-run, reports available updates without modifying any files. + `), + Example: heredoc.Doc(` + # Check and update all skills interactively + $ gh skills update + + # Update specific skills + $ gh skills update mcp-cli git-commit + + # Update all without prompting + $ gh skills update --all + + # Re-download all skills (restore locally modified files) + $ gh skills update --force --all + + # Check for updates without applying (read-only) + $ gh skills update --dry-run + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.Skills = args + if runF != nil { + return runF(opts) + } + return updateRun(opts) + }, + } + + cmd.Flags().BoolVar(&opts.All, "all", false, "Update all skills without prompting") + cmd.Flags().BoolVar(&opts.Force, "force", false, "Re-download even if already up to date") + cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Report available updates without modifying files") + cmd.Flags().StringVar(&opts.Dir, "dir", "", "Scan a custom directory for installed skills") + + return cmd +} + +func updateRun(opts *updateOptions) error { + cs := opts.IO.ColorScheme() + canPrompt := opts.IO.CanPrompt() + + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + cfg, err := opts.Config() + if err != nil { + return err + } + hostname, _ := cfg.Authentication().DefaultHost() + + gitRoot := resolveGitRoot(opts.GitClient) + homeDir := resolveHomeDir() + + // Scan for installed skills + var installed []installedSkill + if opts.Dir != "" { + skills, scanErr := scanInstalledSkills(opts.Dir, nil, "") + if scanErr != nil { + return fmt.Errorf("could not scan directory: %w", scanErr) + } + installed = skills + } else { + installed = scanAllHosts(gitRoot, homeDir) + } + + if len(installed) == 0 { + fmt.Fprintf(opts.IO.ErrOut, "No installed skills found.\n") + return nil + } + + // Filter to requested skills if specified + if len(opts.Skills) > 0 { + requested := make(map[string]bool, len(opts.Skills)) + for _, name := range opts.Skills { + requested[name] = true + } + var filtered []installedSkill + for _, s := range installed { + if requested[s.name] { + filtered = append(filtered, s) + } + } + if len(filtered) == 0 { + return fmt.Errorf("none of the specified skills are installed") + } + installed = filtered + } + + // Prompt for metadata on skills missing it (before starting progress indicator) + var noMeta []string + // Track skills where the user provided a source repo interactively. + // Keyed by directory path to avoid collisions when the same skill name + // is installed across multiple hosts or scopes. + type promptedEntry struct { + name string + source string // "owner/repo" + } + prompted := make(map[string]promptedEntry) // dir → entry + for i := range installed { + s := &installed[i] + if s.owner != "" && s.repo != "" { + continue + } + if !canPrompt { + noMeta = append(noMeta, s.name) + continue + } + fmt.Fprintf(opts.IO.ErrOut, "%s %s has no GitHub metadata\n", cs.WarningIcon(), s.name) + owner, repo, reason, ok, promptErr := promptForSkillOrigin(opts.Prompter, s.name) + if promptErr != nil { + return promptErr + } + if !ok { + if reason != "" { + fmt.Fprintf(opts.IO.ErrOut, " %s %s\n", cs.WarningIcon(), reason) + } + fmt.Fprintf(opts.IO.ErrOut, " Skipping %s\n", s.name) + continue + } + s.owner = owner + s.repo = repo + prompted[s.dir] = promptedEntry{name: s.name, source: owner + "/" + repo} + } + + opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Checking %d installed skill(s) for updates", len(installed))) + + var updates []pendingUpdate + var pinned []installedSkill + + type repoKey struct{ owner, repo string } + repoSkills := make(map[repoKey][]discovery.Skill) + repoRefs := make(map[repoKey]*discovery.ResolvedRef) + repoErrors := make(map[repoKey]bool) + + for _, s := range installed { + if s.owner == "" || s.repo == "" { + continue + } + if s.pinned != "" { + pinned = append(pinned, s) + continue + } + + key := repoKey{s.owner, s.repo} + + if repoErrors[key] { + continue + } + + // Resolve ref and discover skills once per repo + if _, ok := repoRefs[key]; !ok { + resolved, resolveErr := discovery.ResolveRef(apiClient, hostname, s.owner, s.repo, "") + if resolveErr != nil { + repoErrors[key] = true + opts.IO.StopProgressIndicator() + fmt.Fprintf(opts.IO.ErrOut, "%s Skipping %s: could not resolve %s/%s: %v\n", cs.WarningIcon(), s.name, s.owner, s.repo, resolveErr) + opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Checking %d installed skill(s) for updates", len(installed))) + continue + } + repoRefs[key] = resolved + + skills, discoverErr := discovery.DiscoverSkills(apiClient, hostname, s.owner, s.repo, resolved.SHA) + if discoverErr != nil { + repoErrors[key] = true + opts.IO.StopProgressIndicator() + fmt.Fprintf(opts.IO.ErrOut, "%s Skipping %s: %v\n", cs.WarningIcon(), s.name, discoverErr) + opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Checking %d installed skill(s) for updates", len(installed))) + continue + } + repoSkills[key] = skills + } + + resolved := repoRefs[key] + for _, remote := range repoSkills[key] { + matched := false + if s.sourcePath != "" { + matched = remote.Path == s.sourcePath + } else { + matched = remote.InstallName() == s.name + } + if matched && (remote.TreeSHA != s.treeSHA || opts.Force) { + updates = append(updates, pendingUpdate{ + local: s, + newSHA: remote.TreeSHA, + resolved: resolved, + skill: remote, + }) + break + } + } + } + + opts.IO.StopProgressIndicator() + + // Warn about prompted skills that weren't found in the remote repo + for _, entry := range prompted { + parts := strings.SplitN(entry.source, "/", 2) + key := repoKey{parts[0], parts[1]} + skills, resolved := repoSkills[key] + if !resolved { + continue + } + found := false + for _, remote := range skills { + if remote.InstallName() == entry.name || remote.Name == entry.name { + found = true + break + } + } + if !found { + fmt.Fprintf(opts.IO.ErrOut, "%s Skill %s not found in %s\n", cs.WarningIcon(), entry.name, entry.source) + } + } + + for _, s := range pinned { + fmt.Fprintf(opts.IO.ErrOut, "%s %s is pinned to %s (skipped)\n", cs.Gray("⊘"), s.name, s.pinned) + } + for _, name := range noMeta { + fmt.Fprintf(opts.IO.ErrOut, "%s %s has no GitHub metadata — reinstall to enable updates\n", cs.WarningIcon(), name) + } + + if len(updates) == 0 { + if opts.Force && opts.DryRun { + fmt.Fprintf(opts.IO.ErrOut, "All skills are up to date. Use --force without --dry-run to re-download anyway.\n") + } else { + fmt.Fprintf(opts.IO.ErrOut, "All skills are up to date.\n") + } + return nil + } + + fmt.Fprintf(opts.IO.ErrOut, "\n%d update(s) available:\n", len(updates)) + for _, u := range updates { + if u.local.treeSHA == u.newSHA { + fmt.Fprintf(opts.IO.Out, " %s %s (%s/%s) %s (reinstall) [%s]\n", + cs.Cyan("•"), u.local.name, u.local.owner, u.local.repo, + git.ShortSHA(u.newSHA), u.resolved.Ref) + } else { + fmt.Fprintf(opts.IO.Out, " %s %s (%s/%s) %s → %s [%s]\n", + cs.Cyan("•"), u.local.name, u.local.owner, u.local.repo, + cs.Gray(git.ShortSHA(u.local.treeSHA)), git.ShortSHA(u.newSHA), + u.resolved.Ref) + } + } + fmt.Fprintln(opts.IO.ErrOut) + + if opts.DryRun { + return nil + } + + if !opts.All { + if !canPrompt { + return fmt.Errorf("updates available; re-run with --all to apply, or run interactively to confirm") + } + confirmed, confirmErr := opts.Prompter.Confirm(fmt.Sprintf("Update %d skill(s)?", len(updates)), true) + if confirmErr != nil { + return confirmErr + } + if !confirmed { + fmt.Fprintf(opts.IO.ErrOut, "Update cancelled.\n") + return nil + } + } + + var failed bool + for _, u := range updates { + installOpts := &installer.Options{ + Host: hostname, + Owner: u.local.owner, + Repo: u.local.repo, + Ref: u.resolved.Ref, + SHA: u.resolved.SHA, + Skills: []discovery.Skill{u.skill}, + AgentHost: u.local.host, + Scope: u.local.scope, + GitRoot: gitRoot, + HomeDir: homeDir, + Client: apiClient, + } + // When updating skills from a custom --dir, host is nil. + // Use the skill's install root as the target. For namespaced + // skills (name contains "/"), the dir is two levels below the + // root instead of one. + if u.local.host == nil { + base := filepath.Dir(u.local.dir) + if strings.Contains(u.local.name, "/") { + base = filepath.Dir(base) + } + installOpts.Dir = base + } + _, installErr := installer.Install(installOpts) + if installErr != nil { + fmt.Fprintf(opts.IO.ErrOut, "%s Failed to update %s: %v\n", cs.FailureIcon(), u.local.name, installErr) + failed = true + continue + } + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.Out, "%s Updated %s\n", cs.SuccessIcon(), u.local.name) + } else { + fmt.Fprintf(opts.IO.Out, "Updated %s\n", u.local.name) + } + } + + if failed { + return cmdutil.SilentError + } + + return nil +} + +// scanAllHosts walks every known host directory (project + user scope) and +// collects installed skills. Skills are deduplicated by directory path. +func scanAllHosts(gitRoot, homeDir string) []installedSkill { + seen := make(map[string]bool) + var all []installedSkill + + for i := range registry.Agents { + host := ®istry.Agents[i] + for _, scope := range []registry.Scope{registry.ScopeProject, registry.ScopeUser} { + dir, err := host.InstallDir(scope, gitRoot, homeDir) + if err != nil { + continue + } + skills, err := scanInstalledSkills(dir, host, scope) + if err != nil { + continue + } + for _, s := range skills { + if seen[s.dir] { + continue + } + seen[s.dir] = true + all = append(all, s) + } + } + } + + return all +} + +// scanInstalledSkills reads all SKILL.md files in a skills directory and +// extracts GitHub metadata from their frontmatter. It handles both flat +// layouts ({dir}/{name}/SKILL.md) and namespaced layouts +// ({dir}/{namespace}/{name}/SKILL.md). +func scanInstalledSkills(skillsDir string, host *registry.AgentHost, scope registry.Scope) ([]installedSkill, error) { + entries, err := os.ReadDir(skillsDir) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("could not read skills directory: %w", err) + } + + var skills []installedSkill + for _, e := range entries { + if !e.IsDir() { + continue + } + + // Flat layout: {dir}/{name}/SKILL.md + skillFile := filepath.Join(skillsDir, e.Name(), "SKILL.md") + if data, readErr := os.ReadFile(skillFile); readErr == nil { + if s, ok := parseInstalledSkill(data, e.Name(), filepath.Join(skillsDir, e.Name()), host, scope); ok { + skills = append(skills, s) + continue + } + } + + // Namespaced layout: {dir}/{namespace}/{name}/SKILL.md + subEntries, subErr := os.ReadDir(filepath.Join(skillsDir, e.Name())) + if subErr != nil { + continue + } + for _, sub := range subEntries { + if !sub.IsDir() { + continue + } + subSkillFile := filepath.Join(skillsDir, e.Name(), sub.Name(), "SKILL.md") + if data, readErr := os.ReadFile(subSkillFile); readErr == nil { + installName := e.Name() + "/" + sub.Name() + if s, ok := parseInstalledSkill(data, installName, filepath.Join(skillsDir, e.Name(), sub.Name()), host, scope); ok { + skills = append(skills, s) + } + } + } + } + + return skills, nil +} + +// parseInstalledSkill parses a SKILL.md file and returns an installedSkill. +func parseInstalledSkill(data []byte, name, dir string, host *registry.AgentHost, scope registry.Scope) (installedSkill, bool) { + result, err := frontmatter.Parse(string(data)) + if err != nil { + return installedSkill{}, false + } + + s := installedSkill{ + name: name, + dir: dir, + host: host, + scope: scope, + } + + if result.Metadata.Meta != nil { + s.owner, _ = result.Metadata.Meta["github-owner"].(string) + s.repo, _ = result.Metadata.Meta["github-repo"].(string) + s.treeSHA, _ = result.Metadata.Meta["github-tree-sha"].(string) + s.pinned, _ = result.Metadata.Meta["github-pinned"].(string) + s.sourcePath, _ = result.Metadata.Meta["github-path"].(string) + } + + return s, true +} + +// promptForSkillOrigin asks the user for the source repository of a skill +// that has no GitHub metadata. +func promptForSkillOrigin(p prompter.Prompter, skillName string) (owner, repo, reason string, ok bool, err error) { + input, err := p.Input( + fmt.Sprintf("Repository for %s (owner/repo):", skillName), "") + if err != nil { + return "", "", "", false, err + } + input = strings.TrimSpace(input) + if input == "" { + return "", "", "", false, nil + } + r, err := ghrepo.FromFullName(input) + if err != nil { + //nolint:nilerr // intentionally converting parse error into a user-facing validation message + return "", "", fmt.Sprintf("invalid repository %q: expected owner/repo", input), false, nil + } + return r.RepoOwner(), r.RepoName(), "", true, nil +} + +func resolveGitRoot(gc *git.Client) string { + if gc == nil { + if cwd, err := os.Getwd(); err == nil { + return cwd + } + return "" + } + root, err := gc.ToplevelDir(context.Background()) + if err != nil { + if cwd, err := os.Getwd(); err == nil { + return cwd + } + return "" + } + return root +} + +func resolveHomeDir() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return home +} diff --git a/pkg/cmd/skills/update/update_test.go b/pkg/cmd/skills/update/update_test.go new file mode 100644 index 000000000..735536b0d --- /dev/null +++ b/pkg/cmd/skills/update/update_test.go @@ -0,0 +1,391 @@ +package update + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/prompter" + + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCmdUpdate_Help(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{}, + } + + cmd := NewCmdUpdate(f, func(opts *updateOptions) error { + return nil + }) + + assert.Equal(t, "update [...]", cmd.Use) + assert.NotEmpty(t, cmd.Short) + assert.NotEmpty(t, cmd.Long) + assert.NotEmpty(t, cmd.Example) +} + +func TestNewCmdUpdate_Flags(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} + cmd := NewCmdUpdate(f, func(_ *updateOptions) error { return nil }) + + flags := []string{"all", "force", "dry-run", "dir"} + for _, name := range flags { + assert.NotNil(t, cmd.Flags().Lookup(name), "missing flag: --%s", name) + } +} + +func TestNewCmdUpdate_ArgsPassedToOptions(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} + + var gotOpts *updateOptions + cmd := NewCmdUpdate(f, func(opts *updateOptions) error { + gotOpts = opts + return nil + }) + + args, _ := shlex.Split("mcp-cli git-commit --all --force") + cmd.SetArgs(args) + cmd.SetOut(os.Stdout) + cmd.SetErr(os.Stderr) + err := cmd.Execute() + require.NoError(t, err) + assert.Equal(t, []string{"mcp-cli", "git-commit"}, gotOpts.Skills) + assert.True(t, gotOpts.All) + assert.True(t, gotOpts.Force) +} + +func TestScanInstalledSkills(t *testing.T) { + dir := t.TempDir() + + skillDir := filepath.Join(dir, "git-commit") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + content := "---\nname: git-commit\ndescription: Git commit helper\nmetadata:\n github-owner: github\n github-repo: awesome-copilot\n github-tree-sha: abc123\n github-path: skills/git-commit\n---\nBody content\n" + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) + + noMetaDir := filepath.Join(dir, "unknown-skill") + require.NoError(t, os.MkdirAll(noMetaDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(noMetaDir, "SKILL.md"), []byte("---\nname: unknown-skill\n---\nNo metadata here\n"), 0o644)) + + pinnedDir := filepath.Join(dir, "pinned-skill") + require.NoError(t, os.MkdirAll(pinnedDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(pinnedDir, "SKILL.md"), []byte("---\nname: pinned-skill\nmetadata:\n github-owner: octo\n github-repo: skills\n github-tree-sha: def456\n github-pinned: v1.0.0\n---\nPinned content\n"), 0o644)) + + skills, err := scanInstalledSkills(dir, nil, "") + require.NoError(t, err) + assert.Len(t, skills, 3) + + byName := make(map[string]installedSkill) + for _, s := range skills { + byName[s.name] = s + } + + gc := byName["git-commit"] + assert.Equal(t, "github", gc.owner) + assert.Equal(t, "awesome-copilot", gc.repo) + assert.Equal(t, "abc123", gc.treeSHA) + assert.Equal(t, "skills/git-commit", gc.sourcePath) + assert.Empty(t, gc.pinned) + + us := byName["unknown-skill"] + assert.Empty(t, us.owner) + assert.Empty(t, us.repo) + + ps := byName["pinned-skill"] + assert.Equal(t, "v1.0.0", ps.pinned) +} + +func TestScanInstalledSkills_NonExistentDir(t *testing.T) { + skills, err := scanInstalledSkills("/nonexistent/path", nil, "") + require.NoError(t, err) + assert.Nil(t, skills) +} + +func TestScanInstalledSkills_CorruptedYAML(t *testing.T) { + dir := t.TempDir() + skillDir := filepath.Join(dir, "corrupt") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nnot: valid: yaml: [broken\n---\nbody\n"), 0o644)) + + skills, err := scanInstalledSkills(dir, nil, "") + require.NoError(t, err) + assert.Len(t, skills, 0) +} + +func TestPromptForSkillOrigin_Valid(t *testing.T) { + pm := &prompter.PrompterMock{ + InputFunc: func(prompt string, defaultValue string) (string, error) { + return "github/awesome-copilot", nil + }, + } + owner, repo, _, ok, err := promptForSkillOrigin(pm, "test-skill") + require.NoError(t, err) + assert.True(t, ok) + assert.Equal(t, "github", owner) + assert.Equal(t, "awesome-copilot", repo) +} + +func TestPromptForSkillOrigin_Empty(t *testing.T) { + pm := &prompter.PrompterMock{ + InputFunc: func(prompt string, defaultValue string) (string, error) { + return "", nil + }, + } + _, _, _, ok, err := promptForSkillOrigin(pm, "test-skill") + require.NoError(t, err) + assert.False(t, ok) +} + +func TestPromptForSkillOrigin_Invalid(t *testing.T) { + pm := &prompter.PrompterMock{ + InputFunc: func(prompt string, defaultValue string) (string, error) { + return "just-a-name", nil + }, + } + _, _, reason, ok, err := promptForSkillOrigin(pm, "test-skill") + require.NoError(t, err) + assert.False(t, ok) + assert.Contains(t, reason, "invalid repository") +} + +func TestUpdateRun_NoInstalledSkills(t *testing.T) { + ios, _, _, stderr := iostreams.Test() + ios.SetStdoutTTY(false) + + dir := t.TempDir() + + reg := &httpmock.Registry{} + opts := &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + + defer reg.Verify(t) + err := updateRun(opts) + require.NoError(t, err) + assert.Contains(t, stderr.String(), "No installed skills found.") +} + +func TestUpdateRun_SpecificSkillNotInstalled(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(false) + + dir := t.TempDir() + skillDir := filepath.Join(dir, "existing-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: existing-skill\nmetadata:\n github-owner: owner\n github-repo: repo\n github-tree-sha: abc\n---\n"), 0o644)) + + reg := &httpmock.Registry{} + opts := &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + Skills: []string{"nonexistent"}, + } + + defer reg.Verify(t) + err := updateRun(opts) + assert.EqualError(t, err, "none of the specified skills are installed") +} + +func TestUpdateRun_PinnedSkillsSkipped(t *testing.T) { + ios, _, _, stderr := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + dir := t.TempDir() + skillDir := filepath.Join(dir, "pinned-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: pinned-skill\nmetadata:\n github-owner: owner\n github-repo: repo\n github-tree-sha: abc123\n github-pinned: v1.0.0\n---\n"), 0o644)) + + reg := &httpmock.Registry{} + opts := &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + + defer reg.Verify(t) + err := updateRun(opts) + require.NoError(t, err) + assert.Contains(t, stderr.String(), "pinned-skill is pinned to v1.0.0 (skipped)") + assert.Contains(t, stderr.String(), "All skills are up to date.") +} + +func TestUpdateRun_NoMetaSkipsNonInteractive(t *testing.T) { + ios, _, _, stderr := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + + dir := t.TempDir() + skillDir := filepath.Join(dir, "manual-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: manual-skill\n---\nNo metadata\n"), 0o644)) + + reg := &httpmock.Registry{} + opts := &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + + defer reg.Verify(t) + err := updateRun(opts) + require.NoError(t, err) + assert.Contains(t, stderr.String(), "manual-skill has no GitHub metadata") +} + +func TestUpdateRun_AllUpToDate(t *testing.T) { + ios, _, _, stderr := iostreams.Test() + ios.SetStdoutTTY(false) + + dir := t.TempDir() + skillDir := filepath.Join(dir, "my-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: my-skill\nmetadata:\n github-owner: octo\n github-repo: skills\n github-tree-sha: abc123def456\n github-path: skills/my-skill\n---\n"), 0o644)) + + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", "repos/octo/skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/octo/skills/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "commitsha123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/octo/skills/git/trees/commitsha123")), + httpmock.StringResponse(`{"sha": "commitsha123", "tree": [{"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobsha1"}, {"path": "skills/my-skill", "type": "tree", "sha": "abc123def456"}, {"path": "skills", "type": "tree", "sha": "treeshaX"}], "truncated": false}`), + ) + + opts := &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + + defer reg.Verify(t) + err := updateRun(opts) + require.NoError(t, err) + assert.Contains(t, stderr.String(), "All skills are up to date.") +} + +func TestUpdateRun_DryRun(t *testing.T) { + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + dir := t.TempDir() + skillDir := filepath.Join(dir, "my-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: my-skill\nmetadata:\n github-owner: octo\n github-repo: skills\n github-tree-sha: oldsha123\n github-path: skills/my-skill\n---\n"), 0o644)) + + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", "repos/octo/skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v2.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/octo/skills/git/ref/tags/v2.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit456", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/octo/skills/git/trees/newcommit456"), + httpmock.StringResponse(`{"sha": "newcommit456", "tree": [{"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobsha2"}, {"path": "skills/my-skill", "type": "tree", "sha": "newsha456"}, {"path": "skills", "type": "tree", "sha": "treeshaY"}], "truncated": false}`), + ) + + opts := &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + DryRun: true, + } + + defer reg.Verify(t) + err := updateRun(opts) + require.NoError(t, err) + assert.Contains(t, stderr.String(), "1 update(s) available:") + assert.Contains(t, stdout.String(), "my-skill") + assert.Contains(t, stdout.String(), "octo/skills") +} + +func TestUpdateRun_NonInteractiveNoAll(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + + dir := t.TempDir() + skillDir := filepath.Join(dir, "my-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: my-skill\nmetadata:\n github-owner: octo\n github-repo: skills\n github-tree-sha: oldsha123\n github-path: skills/my-skill\n---\n"), 0o644)) + + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", "repos/octo/skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v2.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/octo/skills/git/ref/tags/v2.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit456", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/octo/skills/git/trees/newcommit456"), + httpmock.StringResponse(`{"sha": "newcommit456", "tree": [{"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobsha2"}, {"path": "skills/my-skill", "type": "tree", "sha": "newsha456"}, {"path": "skills", "type": "tree", "sha": "treeshaY"}], "truncated": false}`), + ) + + opts := &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + + defer reg.Verify(t) + err := updateRun(opts) + assert.EqualError(t, err, "updates available; re-run with --all to apply, or run interactively to confirm") +} From 8ea84d0dee4f247a82912fc16e74eab29f9da091 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 3 Apr 2026 12:37:47 -0600 Subject: [PATCH 05/27] Expand test coverage and fix invariants/bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the three primary discovery entry points with httpmock-based tests. DiscoverSkills: happy path, truncated tree, no skills, API error, dedup. DiscoverSkillByPath: path resolution, namespaces, invalid name, missing directory, missing SKILL.md. DiscoverLocalSkills: convention matching, root skill, no skills, nonexistent directory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Test InstallLocal public API instead of private installLocalSkill Replace tests that called installLocalSkill directly with tests through InstallLocal. Adds coverage for AgentHost+Scope resolution path, multiple skills, and missing Dir/AgentHost error. Fixes symlink test to require.NoError on os.Symlink. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Test partial failure in concurrent Install Add test where one of two skills fails (500 on tree fetch). Asserts that result.Installed contains the successful skill and err wraps the failed skill name. Fixes test loop to not clear Dir for partial failure cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Refactor update tests to table-driven pattern Consolidate 16 individual test functions into 3 standalone + 3 table tests matching cli/cli conventions. Fix ArgsPassedToOptions to use iostreams.Test() instead of os.Stdout/os.Stderr. Use GitHub-branded test data. No coverage lost. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Add update execution test that verifies SKILL.md is rewritten All prior update tests used DryRun or hit early exits. New test exercises the full fetch-and-rewrite path: stale treeSHA triggers re-download, SKILL.md is overwritten with new content and metadata. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Use heredoc.Doc for multiline SKILL.md strings in update tests Replace escaped newline strings with heredoc.Doc backtick literals for readability, matching cli/cli conventions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Add interactive update path tests Cover confirm-and-apply, confirm-cancelled, and no-metadata prompt paths in TestUpdateRun. These interactive branches were previously untested since all prior tests used non-TTY or DryRun. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Test no-metadata prompt enrichment through full update path Add test where a skill with no GitHub metadata is prompted for origin, user provides owner/repo, skill gets enriched and proceeds through version resolution and file rewrite. Covers lines 222-224 in update.go. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Replace deprecated cs.Gray with cs.Muted Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Test namespaced skill update with --dir base resolution Cover the filepath.Dir double-up path for namespaced skills (name contains '/') when using --dir. Verifies the install base is resolved correctly so the update writes to the right directory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Test install failure during update reports error and preserves file Cover the path where version resolution succeeds but blob fetch fails during the actual install. Verifies stderr error message, SilentError return, and that the original SKILL.md is not modified. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Dedupe resolveGitRoot/resolveHomeDir into installer, rename scanAllHosts Move ResolveGitRoot and ResolveHomeDir to the installer package to eliminate duplication between install and update commands. Fix ResolveGitRoot to check RepoDir before calling ToplevelDir. Rename scanAllHosts to scanAllAgents to match registry naming. Add test exercising scanAllAgents via updateRun without --dir. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Use heredoc.Doc for multiline YAML strings across all test files Convert 13 escaped-newline frontmatter strings to heredoc.Doc for readability. Applies to discovery, frontmatter, install, update, publish, and preview test files. Preserves edge-case test strings and fmt.Sprintf interpolations as-is. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Use git.Client.Copy() instead of struct copy to avoid mutex copy Fixes go vet 'copies lock value' warnings in publish command where *git.Client was copied by value to set a different RepoDir. Rename terse variable names (bc/ic/dc) to branchGit/ignoreGit/dirGit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Rewrite publish tests: table-driven through publishRun Consolidate 35 test functions into 2: TestNewCmdPublish (4 cases for CLI arg parsing) and TestPublishRun (22 cases exercising all behavior through the command's run function). No individual helper function tests — every codepath tested through publishRun scenarios. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Remove .gitkeep from acceptance/testdata/skills Delete the placeholder .gitkeep file from acceptance/testdata/skills. The directory no longer needs a placeholder file to be tracked in the repository. Rename testPublishGitClient to newTestGitClient Rename the test helper function testPublishGitClient to newTestGitClient in pkg/cmd/skills/publish/publish_test.go and update all call sites accordingly. This is a purely refactor/name-change with no behavioral changes to tests. Fix Windows CI: set USERPROFILE alongside HOME in tests os.UserHomeDir() uses USERPROFILE on Windows, not HOME. All tests that redirect HOME for lockfile isolation now also set USERPROFILE to the same temp directory. Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com> Use range-over-int in acquireLock retry loop Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Test lock acquisition edge cases through RecordInstall Make lockRetries and lockRetryInterval configurable (package-level vars) so tests can avoid the 3s retry wait. Add two RecordInstall cases: - Stale lock (>30s old) is broken and install succeeds - Fresh lock exhausts retries, proceeds best-effort without lock Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Rename test helpers for lockfile tests Rename setupHome to setupTestHome and readLockfile to readTestLockfile in internal/skills/lockfile tests, and update all call sites and comments accordingly. This is a refactor-only change to clarify test helper names with no behavior change. Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com> Test read() degradation through RecordInstall, delete TestRead Move corrupt JSON and wrong version cases into TestRecordInstall table. RecordInstall calls read() internally, so these exercise the same degradation paths through the public API. Verifies the lockfile is rewritten with correct version and new data after recovery. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Fix InstalledAt preservation test to actually prove preservation Move the update-preserves-InstalledAt case out of the table into a standalone subtest that reads InstalledAt between two RecordInstall calls and asserts exact equality. The table version only checked NotEmpty which couldn't detect if InstalledAt was overwritten. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Merge duplicate plugin test into TestMatchSkillConventions table The standalone TestDuplicatePluginSkills_DifferentAuthors re-implemented dedup logic that belongs in DiscoverSkills. Replace with a table case that tests convention matching only. Dedup is already covered by TestDiscoverSkills. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Fix broken validateName max-length test case Replace make([]byte, N) (which produces null bytes) with strings.Repeat to actually test the 64-character boundary. Add positive test for valid 64-char name. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Replace name-matching hack with createDir field in TestDiscoverLocalSkills Use a struct field instead of comparing tt.name to control whether the test directory is created. Prevents silent breakage if someone renames the test case. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Improve collisions tests: table-driven FormatCollisions, exercise DisplayName Convert TestFormatCollisions to table test with nil-input case. Update single collision case to use different conventions (plugins vs skills) so DisplayName() logic is actually exercised in the assertion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Add tests for MatchesSkillPath, DiscoverSkillFiles, ListSkillFiles, FetchDescriptionsConcurrent Also cover previously untested branches: root convention matching, annotated tag dereference failure, empty tag_name/default_branch fallbacks, recursive walkTree with subtrees, and skill directory deduplication. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Test full GitHub key stripping in InjectLocalMetadata Add all 7 github-* keys to the input metadata and assert all are absent after injection. Previously only tested github-owner and github-repo removal. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Test Serialize trailing-newline addition for body without newline Add case where body doesn't end in newline and assert the output has one appended. Previously this branch was uncovered. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Test InjectGitHubMetadata with no existing frontmatter Add case where content has no --- delimiters, exercising the RawYAML == nil branch that creates frontmatter from scratch. Also fix test data to use GitHub-branded names. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Convert TestInjectLocalMetadata to table-driven with no-metadata case Add case for content with no frontmatter, exercising the meta == nil branch. Aligns with table-driven pattern used throughout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Replace name-matching hack with useAgentHost field in TestInstallLocal Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Add tests for ResolveGitRoot Cover RepoDir shortcut, nil client fallback, and empty RepoDir fallback. Skip ResolveHomeDir — it's a thin os.UserHomeDir wrapper with no logic to test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Test OnProgress callback in both single and multi-skill Install paths Cover the progress reporting branches in Install for both the single-skill fast path (len==1) and the concurrent multi-skill path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Cover missing InstallDir error branches and malformed URL in registry Add user-scope-without-homeDir and invalid-scope cases to TestInstallDir. Add malformed URL case to TestRepoNameFromRemote. Coverage 80.5% → 87.8%. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Rewrite install tests: table-driven through installRun and runLocalInstall Consolidate 48 individual test functions into 6: TestNewCmdInstall (10 cases for CLI parsing), TestInstallRun (21 cases for remote install flow), TestRunLocalInstall (10 cases for local install flow), plus TestIsLocalPath, TestIsSkillPath, TestFriendlyDir for pure input classification. Delete zero-value Help test. All behavior tested through public functions instead of calling internal helpers directly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Fix data race in OnProgress test with atomic counter The OnProgress callback was appending to a shared slice from concurrent goroutines. Replace with sync/atomic counter to avoid the race. Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com> Add interactive install tests for skill selection, scope, host, and overwrite Exercise the interactive TTY paths in installRun: MultiSelectWithSearch for skill selection, Select for scope prompt, MultiSelect for host selection, and Confirm for overwrite declined. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Exercise skillSearchFunc fully through interactive mock Update the interactive skill selection test to use 31 skills (exceeding maxSearchResults cap), include a skill without a description, and have the mock call searchFunc with both empty and filtered queries. Verifies the MoreResults count, label formatting, and truncation branches. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Fill remaining install coverage gaps Add local path detection cases to TestNewCmdInstall. Add interactive repo prompt, user scope selection, overwrite without metadata, and single exact match cases to TestInstallRun. Add bare tilde expansion to TestRunLocalInstall. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Move HOME/USERPROFILE setenv to test loops, remove per-case duplication Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Add isTTY field to install test tables, centralize TTY setup Move TTY configuration from individual opts funcs into the test loops. Each table case declares isTTY: true/false and the loop sets all three streams accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Remove INSTALL_TARGET env var hack from install test Metadata injection is already proven by installer package tests. This test only needs to verify installRun orchestrates correctly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Add ScopeChanged: true to all install tests with explicit Scope Ensures tests simulate the same state cobra produces when --scope is explicitly provided, preventing silent codepath divergence if the default scope behavior changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Fix assert.Error → require.Error in TestNewCmdSearch Prevents nil panic on err.Error() if the command unexpectedly returns nil. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Improve preview test quality and coverage - Fix assert.Error/assert.NoError → require.Error/require.NoError to prevent nil panics in TestNewCmdPreview and TestPreviewRun - Add renderAllFiles edge case tests: maxFiles cap (20 files), maxBytes cap (512KB), and FetchBlob error fallback message - Replace custom discardWriter with io.Discard - Use GitHub-branded names (monalisa) in new tests Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com> Add search test coverage: rate limits, owner scope, blob enrichment - Add HTTP 429 and 403+Retry-After rate limit test cases - Add owner-scoped no-results test (exercises noResultsMessage branch) - Add blob description enrichment test (exercises fetchDescriptions path) - Replace custom splitOnSpaces with strings.Fields - Replace custom discardWriter with io.Discard Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com> Remove low-value alias test for preview command The test only asserts a string literal matches another string literal. Alias presence is already visible in the command definition. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Replace local pluralize with text.Pluralize The internal/text package already provides this function via go-gh. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Inline collapseWhitespace — just strings.Fields + Join Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Doc: suggest using go-humanize for star formatting Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com> Return cmdutil.CancelError on user cancellation in publish and update Both commands returned nil (success exit) when the user declined confirmation. The core CLI pattern is to return CancelError so the process exits with a non-zero status. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Add interactive publish prompt tests and isTTY field Cover all prompt branches in runPublishRelease: - Topic confirm + semver tag selection + final confirm (happy path) - Custom tag input path (select idx=1) - Final confirm declined (CancelError) - Immutable releases prompt (enable via PATCH) Add isTTY field to test table struct for centralized TTY setup, matching the pattern used in install tests. Add auto-confirm prompters to existing TTY tests that now need them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Remove duplicate giturl import alias in publish The git package was imported twice — once as 'git' and again as 'giturl'. Use git.ParseURL directly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Fix data race in search enrichment fetchDescriptions and fetchRepoStars run concurrently but both wrote to fields of the same skillResult slice elements, triggering the race detector. Refactor both functions to return index-keyed maps instead of mutating the slice directly. enrichSkills merges the maps into the slice after both goroutines complete. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> refactor: remove Claude plugin branding, align with Open Plugin Spec Replace all 'Claude plugin' references with generic 'plugin' terminology to align with the vendor-neutral Open Plugin Spec (https://github.com/vercel-labs/open-plugin-spec). Changes: - Rename .claude-plugin/ to .plugin/ (spec §5.1 vendor-neutral manifest) - Rename claudePluginJSON/claudeAuthor types to pluginJSON/pluginAuthor - Rename claudeMarketplaceJSON to marketplaceJSON - Rename generateClaudePlugin to generatePlugin - Remove 'Claude Code' from plugin-related comments, help text, and flags - Update install.go plugins/ convention message Factual host references (Claude Code as an agent name, .claude/skills directories) are intentionally preserved — those are product names, not plugin branding. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Remove --plugins flag from publish command Remove the --plugins flag and all associated plugin generation code from the publish flow. This was scope creep — the publish command should focus on validating and publishing skills, not generating plugin manifests. Removed: - --plugins flag and Plugins option field - generatePlugin, generateMarketplace, buildPluginDescription functions - pluginJSON, marketplaceJSON, marketplacePlugin types - Related tests and help text The install command's ability to discover and pluck skills from plugin- structured repositories (plugins/ convention) is preserved. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> don't fall back on default branch if you can't fetch latest release improve search algo by using square rot instead of log for stars, and reduce weight for exact name match add support for --unpin flag when updating a skill --- acceptance/testdata/skills/.gitkeep | 0 internal/skills/discovery/collisions_test.go | 34 +- internal/skills/discovery/discovery_test.go | 654 ++++- .../skills/frontmatter/frontmatter_test.go | 114 +- internal/skills/installer/installer.go | 28 + internal/skills/installer/installer_test.go | 206 +- internal/skills/lockfile/lockfile.go | 9 +- internal/skills/lockfile/lockfile_test.go | 219 +- internal/skills/registry/registry_test.go | 15 + pkg/cmd/skills/install/install.go | 44 +- pkg/cmd/skills/install/install_test.go | 2487 +++++++++++------ pkg/cmd/skills/preview/preview.go | 12 +- pkg/cmd/skills/preview/preview_test.go | 221 +- pkg/cmd/skills/publish/publish.go | 236 +- pkg/cmd/skills/publish/publish_test.go | 2248 ++++++++------- pkg/cmd/skills/search/search.go | 71 +- pkg/cmd/skills/search/search_test.go | 111 +- pkg/cmd/skills/update/update.go | 52 +- pkg/cmd/skills/update/update_test.go | 1352 +++++++-- 19 files changed, 5464 insertions(+), 2649 deletions(-) delete mode 100644 acceptance/testdata/skills/.gitkeep diff --git a/acceptance/testdata/skills/.gitkeep b/acceptance/testdata/skills/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/internal/skills/discovery/collisions_test.go b/internal/skills/discovery/collisions_test.go index b499c497a..fff5199ba 100644 --- a/internal/skills/discovery/collisions_test.go +++ b/internal/skills/discovery/collisions_test.go @@ -21,13 +21,13 @@ func TestFindNameCollisions(t *testing.T) { want: nil, }, { - name: "single collision", + name: "single collision with different conventions", skills: []Skill{ {Name: "pr-summary", Path: "skills/pr-summary"}, - {Name: "pr-summary", Path: "skills/monalisa/pr-summary"}, + {Name: "pr-summary", Path: "plugins/hubot/skills/pr-summary", Convention: "plugins"}, }, want: []NameCollision{ - {Name: "pr-summary", DisplayNames: []string{"pr-summary", "pr-summary"}}, + {Name: "pr-summary", DisplayNames: []string{"pr-summary", "[plugins] pr-summary"}}, }, }, { @@ -53,10 +53,28 @@ func TestFindNameCollisions(t *testing.T) { } func TestFormatCollisions(t *testing.T) { - collisions := []NameCollision{ - {Name: "pr-summary", DisplayNames: []string{"skills/pr-summary", "plugins/hubot/pr-summary"}}, - {Name: "code-review", DisplayNames: []string{"skills/code-review", "skills/monalisa/code-review"}}, + tests := []struct { + name string + collisions []NameCollision + want string + }{ + { + name: "formats multiple collisions", + collisions: []NameCollision{ + {Name: "pr-summary", DisplayNames: []string{"skills/pr-summary", "plugins/hubot/pr-summary"}}, + {Name: "code-review", DisplayNames: []string{"skills/code-review", "skills/monalisa/code-review"}}, + }, + want: "pr-summary: skills/pr-summary, plugins/hubot/pr-summary\n code-review: skills/code-review, skills/monalisa/code-review", + }, + { + name: "nil input returns empty string", + collisions: nil, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, FormatCollisions(tt.collisions)) + }) } - got := FormatCollisions(collisions) - assert.Equal(t, "pr-summary: skills/pr-summary, plugins/hubot/pr-summary\n code-review: skills/code-review, skills/monalisa/code-review", got) } diff --git a/internal/skills/discovery/discovery_test.go b/internal/skills/discovery/discovery_test.go index 5368ad23a..974052530 100644 --- a/internal/skills/discovery/discovery_test.go +++ b/internal/skills/discovery/discovery_test.go @@ -2,8 +2,12 @@ package discovery import ( "net/http" + "os" + "path/filepath" + "strings" "testing" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/pkg/httpmock" "github.com/stretchr/testify/assert" @@ -73,6 +77,29 @@ func TestMatchSkillConventions(t *testing.T) { path: "skills/code-review/README.md", wantNil: true, }, + { + name: "plugin skill from different author", + path: "plugins/monalisa/skills/code-review/SKILL.md", + wantName: "code-review", + wantNamespace: "monalisa", + wantConvention: "plugins", + }, + { + name: "root convention single-skill repo", + path: "code-review/SKILL.md", + wantName: "code-review", + wantConvention: "root", + }, + { + name: "root convention excludes skills dir", + path: "skills/SKILL.md", + wantNil: true, + }, + { + name: "root convention excludes dot-prefixed", + path: ".hidden/SKILL.md", + wantNil: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -89,32 +116,6 @@ func TestMatchSkillConventions(t *testing.T) { } } -func TestDuplicatePluginSkills_DifferentAuthors(t *testing.T) { - entries := []treeEntry{ - {Path: "plugins/monalisa/skills/code-review/SKILL.md", Type: "blob"}, - {Path: "plugins/hubot/skills/code-review/SKILL.md", Type: "blob"}, - } - - seen := make(map[string]bool) - var matches []skillMatch - for _, e := range entries { - m := matchSkillConventions(e) - if m == nil || seen[m.skillDir] { - continue - } - seen[m.skillDir] = true - matches = append(matches, *m) - } - - require.Len(t, matches, 2) - assert.Equal(t, "monalisa", matches[0].namespace) - assert.Equal(t, "hubot", matches[1].namespace) - assert.NotEqual(t, - Skill{Name: matches[0].name, Namespace: matches[0].namespace}.InstallName(), - Skill{Name: matches[1].name, Namespace: matches[1].namespace}.InstallName(), - ) -} - func TestValidateName(t *testing.T) { tests := []struct { name string @@ -122,8 +123,8 @@ func TestValidateName(t *testing.T) { want bool }{ {name: "empty", input: "", want: false}, - {name: "too long", input: string(make([]byte, 65)), want: false}, - {name: "max length", input: "a" + string(make([]byte, 63)), want: false}, // 64 'a's would be valid but []byte gives null bytes + {name: "too long", input: strings.Repeat("a", 65), want: false}, + {name: "max length is valid", input: strings.Repeat("a", 64), want: true}, {name: "contains slash", input: "foo/bar", want: false}, {name: "contains dotdot", input: "foo..bar", want: false}, {name: "starts with dot", input: ".hidden", want: false}, @@ -261,6 +262,57 @@ func TestResolveRef(t *testing.T) { wantRef: "main", wantSHA: "branch-sha", }, + { + name: "annotated tag dereference failure", + version: "v4.0", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v4.0"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "tag-obj-sha", "type": "tag"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/tags/tag-obj-sha"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantErr: "could not dereference annotated tag", + }, + { + name: "empty tag_name in latest release falls back to default branch", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.JSONResponse(map[string]interface{}{"tag_name": ""})) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"})) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/main"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "fallback-sha"}, + })) + }, + wantRef: "main", + wantSHA: "fallback-sha", + }, + { + name: "empty default_branch falls back to main", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": ""})) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/main"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "main-sha"}, + })) + }, + wantRef: "main", + wantSHA: "main-sha", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -339,3 +391,549 @@ func TestFetchBlob(t *testing.T) { }) } } + +func TestDiscoverSkills(t *testing.T) { + tests := []struct { + name string + stubs func(*httpmock.Registry) + wantSkills []string + wantErr string + }{ + { + name: "discovers skills from tree", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "skills/code-review", "type": "tree", "sha": "tree-sha-1"}, + {"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "blob-1"}, + {"path": "skills/issue-triage", "type": "tree", "sha": "tree-sha-2"}, + {"path": "skills/issue-triage/SKILL.md", "type": "blob", "sha": "blob-2"}, + {"path": "README.md", "type": "blob", "sha": "readme"}, + }, + })) + }, + wantSkills: []string{"code-review", "issue-triage"}, + }, + { + name: "truncated tree returns error", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc123", "truncated": true, "tree": []map[string]interface{}{}, + })) + }, + wantErr: "too large", + }, + { + name: "no skills found", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "README.md", "type": "blob", "sha": "readme"}, + }, + })) + }, + wantErr: "no skills found", + }, + { + name: "API error", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantErr: "could not fetch repository tree", + }, + { + name: "deduplicates skills from same directory", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "skills/code-review", "type": "tree", "sha": "tree-sha"}, + {"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "blob-1"}, + {"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "blob-2"}, + }, + })) + }, + wantSkills: []string{"code-review"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + skills, err := DiscoverSkills(client, "github.com", "monalisa", "octocat-skills", "abc123") + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + var names []string + for _, s := range skills { + names = append(names, s.Name) + } + assert.Equal(t, tt.wantSkills, names) + }) + } +} + +func TestDiscoverSkillByPath(t *testing.T) { + tests := []struct { + name string + skillPath string + stubs func(*httpmock.Registry) + wantName string + wantNS string + wantErr string + }{ + { + name: "discovers skill by path", + skillPath: "skills/code-review", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/skills"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "code-review", "path": "skills/code-review", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "blob-sha"}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "blob-sha", "encoding": "base64", "content": "IyBTa2lsbA==", + })) + }, + wantName: "code-review", + }, + { + name: "namespaced path sets namespace", + skillPath: "skills/monalisa/issue-triage", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/skills/monalisa"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "issue-triage", "path": "skills/monalisa/issue-triage", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "blob-sha"}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "blob-sha", "encoding": "base64", "content": "IyBTa2lsbA==", + })) + }, + wantName: "issue-triage", + wantNS: "monalisa", + }, + { + name: "strips trailing SKILL.md from path", + skillPath: "skills/code-review/SKILL.md", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/skills"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "code-review", "path": "skills/code-review", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "blob-sha"}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "blob-sha", "encoding": "base64", "content": "IyBTa2lsbA==", + })) + }, + wantName: "code-review", + }, + { + name: "invalid skill name", + skillPath: "skills/.hidden-skill", + wantErr: "invalid skill name", + }, + { + name: "skill directory not found", + skillPath: "skills/nonexistent", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/skills"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "other-skill", "path": "skills/other-skill", "sha": "tree-sha", "type": "dir"}, + })) + }, + wantErr: "skill directory", + }, + { + name: "no SKILL.md in directory", + skillPath: "skills/code-review", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/skills"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "code-review", "path": "skills/code-review", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "README.md", "type": "blob", "sha": "readme"}, + }, + })) + }, + wantErr: "no SKILL.md found", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + if tt.stubs != nil { + tt.stubs(reg) + } + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + skill, err := DiscoverSkillByPath(client, "github.com", "monalisa", "octocat-skills", "abc123", tt.skillPath) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantName, skill.Name) + assert.Equal(t, tt.wantNS, skill.Namespace) + }) + } +} + +func TestDiscoverLocalSkills(t *testing.T) { + tests := []struct { + name string + createDir bool + setup func(t *testing.T, dir string) + wantSkills []string + wantErr string + }{ + { + name: "discovers skills in skills/ directory", + createDir: true, + setup: func(t *testing.T, dir string) { + t.Helper() + for _, name := range []string{"code-review", "issue-triage"} { + skillDir := filepath.Join(dir, "skills", name) + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# "+name), 0o644)) + } + }, + wantSkills: []string{"code-review", "issue-triage"}, + }, + { + name: "single skill at root", + createDir: true, + setup: func(t *testing.T, dir string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: root-skill + --- + # Root + `)), 0o644)) + }, + wantSkills: []string{"root-skill"}, + }, + { + name: "no skills found", + createDir: true, + setup: func(t *testing.T, dir string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("# Not a skill"), 0o644)) + }, + wantErr: "no skills found", + }, + { + name: "nonexistent directory", + setup: func(t *testing.T, dir string) {}, + wantErr: "could not access", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "repo") + if tt.createDir { + require.NoError(t, os.MkdirAll(dir, 0o755)) + } + tt.setup(t, dir) + + skills, err := DiscoverLocalSkills(dir) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + var names []string + for _, s := range skills { + names = append(names, s.Name) + } + assert.ElementsMatch(t, tt.wantSkills, names) + }) + } +} + +func TestMatchesSkillPath(t *testing.T) { + tests := []struct { + name string + path string + wantName string + }{ + {name: "skills convention", path: "skills/code-review/SKILL.md", wantName: "code-review"}, + {name: "namespaced convention", path: "skills/monalisa/issue-triage/SKILL.md", wantName: "issue-triage"}, + {name: "plugins convention", path: "plugins/hubot/skills/pr-summary/SKILL.md", wantName: "pr-summary"}, + {name: "non-skill file", path: "README.md", wantName: ""}, + {name: "non-SKILL.md in skill dir", path: "skills/code-review/prompt.txt", wantName: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.wantName, MatchesSkillPath(tt.path)) + }) + } +} + +func TestDiscoverSkillFiles(t *testing.T) { + tests := []struct { + name string + stubs func(*httpmock.Registry) + wantPaths []string + wantErr string + }{ + { + name: "returns files with skill path prefix", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "sha1", "size": 10}, + {"path": "scripts/setup.sh", "type": "blob", "sha": "sha2", "size": 50}, + {"path": "scripts", "type": "tree", "sha": "treesub"}, + }, + })) + }, + wantPaths: []string{"skills/code-review/SKILL.md", "skills/code-review/scripts/setup.sh"}, + }, + { + name: "truncated tree falls back to walkTree", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", "truncated": true, "tree": []map[string]interface{}{}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "sha1", "size": 10}, + }, + })) + }, + wantPaths: []string{"skills/code-review/SKILL.md"}, + }, + { + name: "API error", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantErr: "could not fetch skill tree", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + files, err := DiscoverSkillFiles(client, "github.com", "monalisa", "octocat-skills", "tree123", "skills/code-review") + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + var paths []string + for _, f := range files { + paths = append(paths, f.Path) + } + assert.Equal(t, tt.wantPaths, paths) + }) + } +} + +func TestListSkillFiles(t *testing.T) { + tests := []struct { + name string + stubs func(*httpmock.Registry) + wantPaths []string + wantErr string + }{ + { + name: "returns relative paths", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "sha1", "size": 10}, + {"path": "prompt.txt", "type": "blob", "sha": "sha2", "size": 20}, + }, + })) + }, + wantPaths: []string{"SKILL.md", "prompt.txt"}, + }, + { + name: "truncated tree falls back to walkTree with nested subtree", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", "truncated": true, "tree": []map[string]interface{}{}, + })) + // walkTree fetches the top-level tree non-recursively + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "sha1", "size": 10}, + {"path": "scripts", "type": "tree", "sha": "subtree1"}, + }, + })) + // walkTree recurses into the "scripts" subtree + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/subtree1"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "subtree1", + "tree": []map[string]interface{}{ + {"path": "setup.sh", "type": "blob", "sha": "sha2", "size": 50}, + }, + })) + }, + wantPaths: []string{"SKILL.md", "scripts/setup.sh"}, + }, + { + name: "API error", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantErr: "could not fetch skill tree", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + files, err := ListSkillFiles(client, "github.com", "monalisa", "octocat-skills", "tree123") + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + var paths []string + for _, f := range files { + paths = append(paths, f.Path) + } + assert.Equal(t, tt.wantPaths, paths) + }) + } +} + +func TestFetchDescriptionsConcurrent(t *testing.T) { + tests := []struct { + name string + skills []Skill + stubs func(*httpmock.Registry) + wantDescs []string + }{ + { + name: "fetches descriptions for skills without one", + skills: []Skill{ + {Name: "code-review", BlobSHA: "blob1"}, + {Name: "issue-triage", Description: "already set"}, + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob1"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "blob1", "encoding": "base64", + "content": "LS0tCm5hbWU6IGNvZGUtcmV2aWV3CmRlc2NyaXB0aW9uOiBSZXZpZXdzIFBScwotLS0KIyBUZXN0", + })) + }, + wantDescs: []string{"Reviews PRs", "already set"}, + }, + { + name: "no-op when all descriptions set", + skills: []Skill{ + {Name: "code-review", Description: "set"}, + }, + stubs: func(reg *httpmock.Registry) {}, + wantDescs: []string{"set"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + FetchDescriptionsConcurrent(client, "github.com", "monalisa", "octocat-skills", tt.skills, nil) + var descs []string + for _, s := range tt.skills { + descs = append(descs, s.Description) + } + assert.Equal(t, tt.wantDescs, descs) + }) + } +} diff --git a/internal/skills/frontmatter/frontmatter_test.go b/internal/skills/frontmatter/frontmatter_test.go index 02bd1ee0e..51fe09133 100644 --- a/internal/skills/frontmatter/frontmatter_test.go +++ b/internal/skills/frontmatter/frontmatter_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + "github.com/MakeNowJust/heredoc" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -18,8 +19,14 @@ func TestParse(t *testing.T) { wantErr bool }{ { - name: "valid frontmatter", - content: "---\nname: test-skill\ndescription: A test skill\n---\n# Body\n", + name: "valid frontmatter", + content: heredoc.Doc(` + --- + name: test-skill + description: A test skill + --- + # Body + `), wantName: "test-skill", wantDesc: "A test skill", wantBody: "# Body\n", @@ -71,18 +78,24 @@ func TestInjectGitHubMetadata(t *testing.T) { wantNotContain []string }{ { - name: "injects metadata without pin", - content: "---\nname: my-skill\ndescription: desc\n---\n# Body\n", - owner: "owner", - repo: "repo", + name: "injects metadata without pin", + content: heredoc.Doc(` + --- + name: my-skill + description: desc + --- + # Body + `), + owner: "monalisa", + repo: "octocat-skills", ref: "v1.0.0", sha: "abc123", treeSHA: "tree456", pinnedRef: "", skillPath: "skills/my-skill", wantContains: []string{ - "github-owner: owner", - "github-repo: repo", + "github-owner: monalisa", + "github-repo: octocat-skills", "github-ref: v1.0.0", "github-sha: abc123", "github-tree-sha: tree456", @@ -94,10 +107,15 @@ func TestInjectGitHubMetadata(t *testing.T) { }, }, { - name: "injects pinned ref", - content: "---\nname: my-skill\n---\n# Body\n", - owner: "owner", - repo: "repo", + name: "injects pinned ref", + content: heredoc.Doc(` + --- + name: my-skill + --- + # Body + `), + owner: "monalisa", + repo: "octocat-skills", ref: "v1.0.0", sha: "abc", treeSHA: "tree", @@ -107,6 +125,22 @@ func TestInjectGitHubMetadata(t *testing.T) { "github-pinned: v1.0.0", }, }, + { + name: "injects metadata into content with no frontmatter", + content: "# Body only\n", + owner: "monalisa", + repo: "octocat-skills", + ref: "v1.0.0", + sha: "abc123", + treeSHA: "tree456", + pinnedRef: "", + skillPath: "skills/my-skill", + wantContains: []string{ + "github-owner: monalisa", + "github-repo: octocat-skills", + "# Body only", + }, + }, } for _, tt := range tests { @@ -124,13 +158,49 @@ func TestInjectGitHubMetadata(t *testing.T) { } func TestInjectLocalMetadata(t *testing.T) { - content := "---\nname: my-skill\nmetadata:\n github-owner: old\n github-repo: old\n---\n# Body\n" - got, err := InjectLocalMetadata(content, "/home/user/skills/my-skill") - require.NoError(t, err) - - assert.Contains(t, got, "local-path: /home/user/skills/my-skill") - assert.NotContains(t, got, "github-owner") - assert.NotContains(t, got, "github-repo") + tests := []struct { + name string + content string + wantContains []string + wantNotContain []string + }{ + { + name: "strips all github keys and injects local-path", + content: heredoc.Doc(` + --- + name: my-skill + metadata: + github-owner: old + github-repo: old + github-ref: v1.0.0 + github-sha: abc123 + github-tree-sha: tree456 + github-pinned: v1.0.0 + github-path: skills/my-skill + --- + # Body + `), + wantContains: []string{"local-path: /home/monalisa/skills/my-skill"}, + wantNotContain: []string{"github-owner", "github-repo", "github-ref", "github-sha", "github-tree-sha", "github-pinned", "github-path"}, + }, + { + name: "injects into content with no existing metadata", + content: "# Body only\n", + wantContains: []string{"local-path: /home/monalisa/skills/my-skill"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := InjectLocalMetadata(tt.content, "/home/monalisa/skills/my-skill") + require.NoError(t, err) + for _, s := range tt.wantContains { + assert.Contains(t, got, s) + } + for _, s := range tt.wantNotContain { + assert.NotContains(t, got, s) + } + }) + } } func TestSerialize(t *testing.T) { @@ -158,6 +228,12 @@ func TestSerialize(t *testing.T) { body: "", wantSuffix: "---\n", }, + { + name: "body without trailing newline gets one added", + frontmatter: map[string]interface{}{"name": "test"}, + body: "# No trailing newline", + wantSuffix: "# No trailing newline\n", + }, } for _, tt := range tests { diff --git a/internal/skills/installer/installer.go b/internal/skills/installer/installer.go index ed2db5074..8ae3da28f 100644 --- a/internal/skills/installer/installer.go +++ b/internal/skills/installer/installer.go @@ -1,6 +1,7 @@ package installer import ( + "context" "errors" "fmt" "os" @@ -9,6 +10,7 @@ import ( "sync" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/safepaths" "github.com/cli/cli/v2/internal/skills/discovery" "github.com/cli/cli/v2/internal/skills/frontmatter" @@ -295,3 +297,29 @@ func installSkill(opts *Options, skill discovery.Skill, baseDir string) error { return nil } + +// ResolveGitRoot returns the git repository root using the provided client, +// falling back to the current working directory on error. +func ResolveGitRoot(gc *git.Client) string { + if gc != nil && gc.RepoDir != "" { + return gc.RepoDir + } + if gc != nil { + if root, err := gc.ToplevelDir(context.Background()); err == nil { + return root + } + } + if cwd, err := os.Getwd(); err == nil { + return cwd + } + return "" +} + +// ResolveHomeDir returns the user's home directory, or "" on error. +func ResolveHomeDir() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return home +} diff --git a/internal/skills/installer/installer_test.go b/internal/skills/installer/installer_test.go index 2f6e09ca8..0637e9c19 100644 --- a/internal/skills/installer/installer_test.go +++ b/internal/skills/installer/installer_test.go @@ -6,26 +6,30 @@ import ( "net/http" "os" "path/filepath" - "strings" + "sync/atomic" "testing" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/internal/skills/registry" "github.com/cli/cli/v2/pkg/httpmock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestInstallLocalSkill(t *testing.T) { +func TestInstallLocal(t *testing.T) { tests := []struct { - name string - skill discovery.Skill - setup func(t *testing.T, srcDir string) - verify func(t *testing.T, destDir string) + name string + skills []discovery.Skill + useAgentHost bool + setup func(t *testing.T, srcDir string) + verify func(t *testing.T, destDir string) + wantErr string }{ { - name: "copies files", - skill: discovery.Skill{Name: "code-review", Path: "skills/code-review"}, + name: "copies files via Dir", + skills: []discovery.Skill{{Name: "code-review", Path: "skills/code-review"}}, setup: func(t *testing.T, srcDir string) { t.Helper() skillSrc := filepath.Join(srcDir, "skills", "code-review") @@ -44,8 +48,8 @@ func TestInstallLocalSkill(t *testing.T) { }, }, { - name: "nested directories", - skill: discovery.Skill{Name: "issue-triage", Path: "skills/issue-triage"}, + name: "nested directories", + skills: []discovery.Skill{{Name: "issue-triage", Path: "skills/issue-triage"}}, setup: func(t *testing.T, srcDir string) { t.Helper() deep := filepath.Join(srcDir, "skills", "issue-triage", "prompts", "templates") @@ -62,15 +66,15 @@ func TestInstallLocalSkill(t *testing.T) { }, }, { - name: "skips symlinks", - skill: discovery.Skill{Name: "pr-summary", Path: "skills/pr-summary"}, + name: "skips symlinks", + skills: []discovery.Skill{{Name: "pr-summary", Path: "skills/pr-summary"}}, setup: func(t *testing.T, srcDir string) { t.Helper() skillSrc := filepath.Join(srcDir, "skills", "pr-summary") require.NoError(t, os.MkdirAll(skillSrc, 0o755)) require.NoError(t, os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("# PR Summary"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(skillSrc, "prompt.txt"), []byte("summarize"), 0o644)) - os.Symlink(filepath.Join(skillSrc, "prompt.txt"), filepath.Join(skillSrc, "link.txt")) + require.NoError(t, os.Symlink(filepath.Join(skillSrc, "prompt.txt"), filepath.Join(skillSrc, "link.txt"))) }, verify: func(t *testing.T, destDir string) { t.Helper() @@ -81,8 +85,8 @@ func TestInstallLocalSkill(t *testing.T) { }, }, { - name: "injects metadata into SKILL.md", - skill: discovery.Skill{Name: "copilot-helper", Path: "skills/copilot-helper"}, + name: "injects metadata into SKILL.md", + skills: []discovery.Skill{{Name: "copilot-helper", Path: "skills/copilot-helper"}}, setup: func(t *testing.T, srcDir string) { t.Helper() skillSrc := filepath.Join(srcDir, "skills", "copilot-helper") @@ -93,10 +97,53 @@ func TestInstallLocalSkill(t *testing.T) { t.Helper() content, err := os.ReadFile(filepath.Join(destDir, "copilot-helper", "SKILL.md")) require.NoError(t, err) - assert.True(t, strings.Contains(string(content), "local-path"), - "expected SKILL.md to contain local-path metadata, got: %s", string(content)) + assert.Contains(t, string(content), "local-path") }, }, + { + name: "multiple skills", + skills: []discovery.Skill{ + {Name: "code-review", Path: "skills/code-review"}, + {Name: "issue-triage", Path: "skills/issue-triage"}, + }, + setup: func(t *testing.T, srcDir string) { + t.Helper() + for _, name := range []string{"code-review", "issue-triage"} { + skillSrc := filepath.Join(srcDir, "skills", name) + require.NoError(t, os.MkdirAll(skillSrc, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("# "+name), 0o644)) + } + }, + verify: func(t *testing.T, destDir string) { + t.Helper() + _, err := os.Stat(filepath.Join(destDir, "code-review", "SKILL.md")) + assert.NoError(t, err) + _, err = os.Stat(filepath.Join(destDir, "issue-triage", "SKILL.md")) + assert.NoError(t, err) + }, + }, + { + name: "resolves install dir from AgentHost and Scope", + skills: []discovery.Skill{{Name: "code-review", Path: "skills/code-review"}}, + useAgentHost: true, + setup: func(t *testing.T, srcDir string) { + t.Helper() + skillSrc := filepath.Join(srcDir, "skills", "code-review") + require.NoError(t, os.MkdirAll(skillSrc, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("# Code Review"), 0o644)) + }, + verify: func(t *testing.T, destDir string) { + t.Helper() + _, err := os.Stat(filepath.Join(destDir, ".github", "skills", "code-review", "SKILL.md")) + assert.NoError(t, err) + }, + }, + { + name: "no dir or agent host", + skills: []discovery.Skill{{Name: "code-review"}}, + setup: func(t *testing.T, srcDir string) {}, + wantErr: "either Dir or AgentHost must be specified", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -104,8 +151,32 @@ func TestInstallLocalSkill(t *testing.T) { destDir := t.TempDir() tt.setup(t, srcDir) - err := installLocalSkill(srcDir, tt.skill, destDir) + opts := &LocalOptions{ + SourceDir: srcDir, + Skills: tt.skills, + Dir: destDir, + } + if tt.useAgentHost { + host, err := registry.FindByID("github-copilot") + require.NoError(t, err) + opts.Dir = "" + opts.AgentHost = host + opts.Scope = registry.ScopeProject + opts.GitRoot = destDir + } + if tt.wantErr != "" { + opts.Dir = "" + } + + result, err := InstallLocal(opts) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } require.NoError(t, err) + assert.NotEmpty(t, result.Dir) + assert.Len(t, result.Installed, len(tt.skills)) tt.verify(t, destDir) }) } @@ -258,23 +329,31 @@ func stubTreeAndBlob(reg *httpmock.Registry, treeSHA string) { } func TestInstall(t *testing.T) { + var progressCount atomic.Int32 + tests := []struct { name string skills []discovery.Skill stubs func(*httpmock.Registry) + onProgress func(done, total int) wantInstalled []string wantErr string }{ { - name: "single skill", + name: "single skill calls OnProgress", skills: []discovery.Skill{ {Name: "code-review", Path: "skills/code-review", TreeSHA: "tree-cr"}, }, - stubs: func(reg *httpmock.Registry) { stubTreeAndBlob(reg, "tree-cr") }, + stubs: func(reg *httpmock.Registry) { stubTreeAndBlob(reg, "tree-cr") }, + onProgress: func(done, total int) { + + progressCount.Add(1) + + }, wantInstalled: []string{"code-review"}, }, { - name: "multiple skills concurrently", + name: "multiple skills concurrently with progress", skills: []discovery.Skill{ {Name: "code-review", Path: "skills/code-review", TreeSHA: "tree-cr"}, {Name: "issue-triage", Path: "skills/issue-triage", TreeSHA: "tree-it"}, @@ -283,8 +362,28 @@ func TestInstall(t *testing.T) { stubTreeAndBlob(reg, "tree-cr") stubTreeAndBlob(reg, "tree-it") }, + onProgress: func(done, total int) { + + progressCount.Add(1) + + }, wantInstalled: []string{"code-review", "issue-triage"}, }, + { + name: "partial failure returns successful installs and error", + skills: []discovery.Skill{ + {Name: "code-review", Path: "skills/code-review", TreeSHA: "tree-cr"}, + {Name: "issue-triage", Path: "skills/issue-triage", TreeSHA: "tree-fail"}, + }, + stubs: func(reg *httpmock.Registry) { + stubTreeAndBlob(reg, "tree-cr") + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-fail"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantInstalled: []string{"code-review"}, + wantErr: "failed to install skill", + }, { name: "no dir or agent host", skills: []discovery.Skill{{Name: "code-review"}}, @@ -294,7 +393,10 @@ func TestInstall(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + progressCount.Store(0) + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) destDir := t.TempDir() reg := &httpmock.Registry{} @@ -303,16 +405,17 @@ func TestInstall(t *testing.T) { client := api.NewClientFromHTTP(&http.Client{Transport: reg}) opts := &Options{ - Host: "github.com", - Owner: "monalisa", - Repo: "octocat-skills", - Ref: "v1.0", - SHA: "commit123", - Client: client, - Skills: tt.skills, - Dir: destDir, + Host: "github.com", + Owner: "monalisa", + Repo: "octocat-skills", + Ref: "v1.0", + SHA: "commit123", + Client: client, + Skills: tt.skills, + Dir: destDir, + OnProgress: tt.onProgress, } - if tt.wantErr != "" { + if tt.wantErr != "" && len(tt.wantInstalled) == 0 { opts.Dir = "" } @@ -320,19 +423,58 @@ func TestInstall(t *testing.T) { if tt.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErr) + if len(tt.wantInstalled) > 0 { + require.NotNil(t, result, "partial failure should return non-nil result") + assert.ElementsMatch(t, tt.wantInstalled, result.Installed) + } return } require.NoError(t, err) assert.ElementsMatch(t, tt.wantInstalled, result.Installed) assert.Equal(t, destDir, result.Dir) - homeDir, _ := os.UserHomeDir() + homeDir, _ = os.UserHomeDir() lockPath := filepath.Join(homeDir, ".agents", ".skill-lock.json") lockData, err := os.ReadFile(lockPath) require.NoError(t, err, "lockfile should have been written") for _, name := range tt.wantInstalled { assert.Contains(t, string(lockData), name) } + if tt.onProgress != nil { + assert.True(t, progressCount.Load() > 0, "OnProgress should have been called") + } + }) + } +} + +func TestResolveGitRoot(t *testing.T) { + tests := []struct { + name string + client *git.Client + wantDir string + }{ + { + name: "returns RepoDir when set", + client: &git.Client{RepoDir: "/monalisa/repo"}, + wantDir: "/monalisa/repo", + }, + { + name: "nil client falls back to cwd", + client: nil, + }, + { + name: "empty RepoDir falls back to ToplevelDir or cwd", + client: &git.Client{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ResolveGitRoot(tt.client) + if tt.wantDir != "" { + assert.Equal(t, tt.wantDir, got) + } else { + assert.NotEmpty(t, got, "should fall back to ToplevelDir or cwd") + } }) } } diff --git a/internal/skills/lockfile/lockfile.go b/internal/skills/lockfile/lockfile.go index 5761d24cf..3a6ccd893 100644 --- a/internal/skills/lockfile/lockfile.go +++ b/internal/skills/lockfile/lockfile.go @@ -131,6 +131,11 @@ func newFile() *file { } } +var ( + lockRetries = 30 + lockRetryInterval = 100 * time.Millisecond +) + // acquireLock creates an exclusive lock file to serialize concurrent access. // Returns an unlock function. If locking fails after retries, it proceeds // unlocked rather than blocking the user indefinitely. @@ -146,7 +151,7 @@ func acquireLock() (unlock func()) { return func() {} } - for i := 0; i < 30; i++ { + for range lockRetries { f, createErr := os.OpenFile(lkPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) if createErr == nil { f.Close() @@ -162,7 +167,7 @@ func acquireLock() (unlock func()) { os.Remove(lkPath) continue } - time.Sleep(100 * time.Millisecond) + time.Sleep(lockRetryInterval) } // Best-effort: proceed without lock. diff --git a/internal/skills/lockfile/lockfile_test.go b/internal/skills/lockfile/lockfile_test.go index b53d6aafc..d4a44f76d 100644 --- a/internal/skills/lockfile/lockfile_test.go +++ b/internal/skills/lockfile/lockfile_test.go @@ -5,16 +5,18 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// setupHome redirects HOME to a temp dir and returns the expected lockfile path. -func setupHome(t *testing.T) string { +// setupTestHome redirects HOME to a temp dir and returns the expected lockfile path. +func setupTestHome(t *testing.T) string { t.Helper() home := t.TempDir() t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) return filepath.Join(home, agentsDir, lockFile) } @@ -39,7 +41,7 @@ func TestRecordInstall(t *testing.T) { treeSHA: "abc123", verify: func(t *testing.T, lockPath string) { t.Helper() - f := readLockfile(t, lockPath) + f := readTestLockfile(t, lockPath) require.Contains(t, f.Skills, "code-review") e := f.Skills["code-review"] assert.Equal(t, "monalisa/octocat-skills", e.Source) @@ -62,30 +64,10 @@ func TestRecordInstall(t *testing.T) { pinnedRef: "v1.0.0", verify: func(t *testing.T, lockPath string) { t.Helper() - f := readLockfile(t, lockPath) + f := readTestLockfile(t, lockPath) assert.Equal(t, "v1.0.0", f.Skills["pr-summary"].PinnedRef) }, }, - { - name: "update preserves InstalledAt and updates treeSHA", - setup: func(t *testing.T) { - t.Helper() - require.NoError(t, RecordInstall("code-review", "monalisa", "octocat-skills", "skills/code-review/SKILL.md", "old-sha", "")) - }, - skill: "code-review", - owner: "monalisa", - repo: "octocat-skills", - skillPath: "skills/code-review/SKILL.md", - treeSHA: "new-sha", - verify: func(t *testing.T, lockPath string) { - t.Helper() - f := readLockfile(t, lockPath) - e := f.Skills["code-review"] - assert.Equal(t, "new-sha", e.SkillFolderHash, "treeSHA should be updated") - // InstalledAt should be preserved (not empty proves it wasn't clobbered) - assert.NotEmpty(t, e.InstalledAt, "InstalledAt should be preserved from first install") - }, - }, { name: "multiple skills coexist", setup: func(t *testing.T) { @@ -99,15 +81,118 @@ func TestRecordInstall(t *testing.T) { treeSHA: "sha2", verify: func(t *testing.T, lockPath string) { t.Helper() - f := readLockfile(t, lockPath) + f := readTestLockfile(t, lockPath) assert.Contains(t, f.Skills, "code-review") assert.Contains(t, f.Skills, "issue-triage") }, }, + { + name: "succeeds despite stale lock file", + setup: func(t *testing.T) { + t.Helper() + lockPath, err := lockfilePath() + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755)) + lkPath := lockPath + ".lk" + f, err := os.Create(lkPath) + require.NoError(t, err) + f.Close() + staleTime := time.Now().Add(-60 * time.Second) + require.NoError(t, os.Chtimes(lkPath, staleTime, staleTime)) + }, + skill: "code-review", + owner: "monalisa", + repo: "octocat-skills", + skillPath: "skills/code-review/SKILL.md", + treeSHA: "abc123", + verify: func(t *testing.T, lockPath string) { + t.Helper() + f := readTestLockfile(t, lockPath) + require.Contains(t, f.Skills, "code-review") + _, err := os.Stat(lockPath + ".lk") + assert.True(t, os.IsNotExist(err), "stale lock should be removed after RecordInstall") + }, + }, + { + name: "proceeds without lock after retries exhausted", + setup: func(t *testing.T) { + t.Helper() + // Reduce retries to avoid 3s wait in tests. + origRetries := lockRetries + origInterval := lockRetryInterval + lockRetries = 1 + lockRetryInterval = 0 + t.Cleanup(func() { + lockRetries = origRetries + lockRetryInterval = origInterval + }) + // Create a fresh (non-stale) lock file that won't be broken. + lockPath, err := lockfilePath() + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755)) + f, err := os.Create(lockPath + ".lk") + require.NoError(t, err) + f.Close() + }, + skill: "code-review", + owner: "monalisa", + repo: "octocat-skills", + skillPath: "skills/code-review/SKILL.md", + treeSHA: "abc123", + verify: func(t *testing.T, lockPath string) { + t.Helper() + f := readTestLockfile(t, lockPath) + require.Contains(t, f.Skills, "code-review", "should succeed best-effort without lock") + }, + }, + { + name: "recovers from corrupt lockfile", + setup: func(t *testing.T) { + t.Helper() + lockPath, err := lockfilePath() + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755)) + require.NoError(t, os.WriteFile(lockPath, []byte("{invalid json"), 0o644)) + }, + skill: "code-review", + owner: "monalisa", + repo: "octocat-skills", + skillPath: "skills/code-review/SKILL.md", + treeSHA: "abc123", + verify: func(t *testing.T, lockPath string) { + t.Helper() + f := readTestLockfile(t, lockPath) + assert.Equal(t, lockVersion, f.Version) + require.Contains(t, f.Skills, "code-review") + }, + }, + { + name: "recovers from wrong version lockfile", + setup: func(t *testing.T) { + t.Helper() + lockPath, err := lockfilePath() + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755)) + data, _ := json.Marshal(file{Version: 999, Skills: map[string]entry{"old-skill": {}}}) + require.NoError(t, os.WriteFile(lockPath, data, 0o644)) + }, + skill: "code-review", + owner: "monalisa", + repo: "octocat-skills", + skillPath: "skills/code-review/SKILL.md", + treeSHA: "abc123", + verify: func(t *testing.T, lockPath string) { + t.Helper() + f := readTestLockfile(t, lockPath) + assert.Equal(t, lockVersion, f.Version) + require.Contains(t, f.Skills, "code-review") + assert.NotContains(t, f.Skills, "old-skill", "wrong-version data should be discarded") + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - lockPath := setupHome(t) + lockPath := setupTestHome(t) if tt.setup != nil { tt.setup(t) } @@ -117,73 +202,25 @@ func TestRecordInstall(t *testing.T) { tt.verify(t, lockPath) }) } + + // This case lives outside the table because it needs to read the lockfile + // between two RecordInstall calls to capture the first InstalledAt value. + t.Run("update preserves InstalledAt and updates treeSHA", func(t *testing.T) { + lockPath := setupTestHome(t) + + require.NoError(t, RecordInstall("code-review", "monalisa", "octocat-skills", "skills/code-review/SKILL.md", "old-sha", "")) + firstInstalledAt := readTestLockfile(t, lockPath).Skills["code-review"].InstalledAt + + require.NoError(t, RecordInstall("code-review", "monalisa", "octocat-skills", "skills/code-review/SKILL.md", "new-sha", "")) + entry := readTestLockfile(t, lockPath).Skills["code-review"] + + assert.Equal(t, "new-sha", entry.SkillFolderHash, "treeSHA should be updated") + assert.Equal(t, firstInstalledAt, entry.InstalledAt, "InstalledAt should be preserved from first install") + }) } -func TestRead(t *testing.T) { - tests := []struct { - name string - setup func(t *testing.T, lockPath string) - wantSkill bool - }{ - { - name: "missing file returns fresh state", - setup: func(t *testing.T, lockPath string) {}, - }, - { - name: "corrupt JSON returns fresh state", - setup: func(t *testing.T, lockPath string) { - t.Helper() - require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755)) - require.NoError(t, os.WriteFile(lockPath, []byte("{invalid json"), 0o644)) - }, - }, - { - name: "wrong version returns fresh state", - setup: func(t *testing.T, lockPath string) { - t.Helper() - require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755)) - data, _ := json.Marshal(file{Version: 999, Skills: map[string]entry{"x": {}}}) - require.NoError(t, os.WriteFile(lockPath, data, 0o644)) - }, - }, - { - name: "valid lockfile", - setup: func(t *testing.T, lockPath string) { - t.Helper() - require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755)) - f := &file{ - Version: lockVersion, - Skills: map[string]entry{ - "code-review": {Source: "monalisa/octocat-skills", SourceType: "github"}, - }, - } - data, err := json.MarshalIndent(f, "", " ") - require.NoError(t, err) - require.NoError(t, os.WriteFile(lockPath, data, 0o644)) - }, - wantSkill: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - lockPath := setupHome(t) - tt.setup(t, lockPath) - - loaded, err := read() - require.NoError(t, err) - assert.Equal(t, lockVersion, loaded.Version) - - if tt.wantSkill { - assert.Contains(t, loaded.Skills, "code-review") - } else { - assert.Empty(t, loaded.Skills) - } - }) - } -} - -// readLockfile is a test helper that reads and parses the lockfile from disk. -func readLockfile(t *testing.T, path string) *file { +// readTestLockfile is a test helper that reads and parses the lockfile from disk. +func readTestLockfile(t *testing.T, path string) *file { t.Helper() data, err := os.ReadFile(path) require.NoError(t, err, "lockfile should exist at %s", path) diff --git a/internal/skills/registry/registry_test.go b/internal/skills/registry/registry_test.go index f37c35e96..e17668b87 100644 --- a/internal/skills/registry/registry_test.go +++ b/internal/skills/registry/registry_test.go @@ -70,6 +70,20 @@ func TestInstallDir(t *testing.T) { homeDir: "/home/monalisa", wantErr: true, }, + { + name: "user scope without home dir", + scope: ScopeUser, + gitRoot: "/tmp/monalisa-repo", + homeDir: "", + wantErr: true, + }, + { + name: "invalid scope", + scope: "bogus", + gitRoot: "/tmp/monalisa-repo", + homeDir: "/home/monalisa", + wantErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -95,6 +109,7 @@ func TestRepoNameFromRemote(t *testing.T) { {"git@github.com:monalisa/octocat-skills", "monalisa/octocat-skills"}, {"ssh://git@github.com/monalisa/octocat-skills.git", "monalisa/octocat-skills"}, {"ssh://git@github.com/monalisa/octocat-skills", "monalisa/octocat-skills"}, + {"not-a-url", ""}, {"", ""}, } for _, tt := range tests { diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index eac2e4a00..8188c8f3f 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -1,7 +1,6 @@ package install import ( - "context" "errors" "fmt" "io" @@ -284,8 +283,8 @@ func installRun(opts *installOptions) error { return err } - gitRoot := resolveGitRoot(opts.GitClient) - homeDir := resolveHomeDir() + gitRoot := installer.ResolveGitRoot(opts.GitClient) + homeDir := installer.ResolveHomeDir() source = ghrepo.FullName(opts.repo) type hostPlan struct { @@ -423,8 +422,8 @@ func runLocalInstall(opts *installOptions) error { return err } - gitRoot := resolveGitRoot(opts.GitClient) - homeDir := resolveHomeDir() + gitRoot := installer.ResolveGitRoot(opts.GitClient) + homeDir := installer.ResolveHomeDir() type hostPlan struct { host *registry.AgentHost @@ -570,7 +569,7 @@ func logConventions(io *iostreams.IOStreams, skills []discovery.Skill) { fmt.Fprintf(io.ErrOut, "Note: found %d namespaced skill(s) in skills/{author}/ directories\n", n) } if n, ok := conventions["plugins"]; ok { - fmt.Fprintf(io.ErrOut, "Note: found %d skill(s) using the Claude Code plugins/ convention\n", n) + fmt.Fprintf(io.ErrOut, "Note: found %d skill(s) using the plugins/ convention\n", n) } if n, ok := conventions["root"]; ok { fmt.Fprintf(io.ErrOut, "Note: found %d skill(s) at the repository root\n", n) @@ -952,7 +951,7 @@ func printFileTree(w io.Writer, cs *iostreams.ColorScheme, dir string, skillName func printTreeDir(w io.Writer, cs *iostreams.ColorScheme, dir, indent string) { entries, err := os.ReadDir(dir) if err != nil { - fmt.Fprintf(w, "%s%s\n", indent, cs.Gray("(could not read directory)")) + fmt.Fprintf(w, "%s%s\n", indent, cs.Muted("(could not read directory)")) return } for i, entry := range entries { @@ -965,10 +964,10 @@ func printTreeDir(w io.Writer, cs *iostreams.ColorScheme, dir, indent string) { } name := entry.Name() if entry.IsDir() { - fmt.Fprintf(w, "%s%s%s\n", indent, cs.Gray(connector), cs.Bold(name+"/")) - printTreeDir(w, cs, filepath.Join(dir, name), indent+cs.Gray(childIndent)) + fmt.Fprintf(w, "%s%s%s\n", indent, cs.Muted(connector), cs.Bold(name+"/")) + printTreeDir(w, cs, filepath.Join(dir, name), indent+cs.Muted(childIndent)) } else { - fmt.Fprintf(w, "%s%s%s\n", indent, cs.Gray(connector), name) + fmt.Fprintf(w, "%s%s%s\n", indent, cs.Muted(connector), name) } } } @@ -990,28 +989,3 @@ func printReviewHint(w io.Writer, cs *iostreams.ColorScheme, repo string, skillN } fmt.Fprintln(w) } - -func resolveGitRoot(gc *git.Client) string { - if gc == nil { - if cwd, err := os.Getwd(); err == nil { - return cwd - } - return "" - } - root, err := gc.ToplevelDir(context.Background()) - if err != nil { - if cwd, err := os.Getwd(); err == nil { - return cwd - } - return "" - } - return root -} - -func resolveHomeDir() string { - home, err := os.UserHomeDir() - if err != nil { - return "" - } - return home -} diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index 658815b63..a4f67f1f1 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -1,6 +1,7 @@ package install import ( + "bytes" "encoding/base64" "fmt" "net/http" @@ -9,11 +10,11 @@ import ( "strings" "testing" + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" - "github.com/cli/cli/v2/internal/skills/discovery" - "github.com/cli/cli/v2/internal/skills/registry" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" @@ -22,633 +23,90 @@ import ( "github.com/stretchr/testify/require" ) -func TestNewCmdInstall_Help(t *testing.T) { - ios, _, _, _ := iostreams.Test() - f := &cmdutil.Factory{ - IOStreams: ios, - Prompter: &prompter.PrompterMock{}, - GitClient: &git.Client{}, - } - - cmd := NewCmdInstall(f, func(opts *installOptions) error { - return nil - }) - - assert.Equal(t, "install []", cmd.Use) - assert.NotEmpty(t, cmd.Short) - assert.NotEmpty(t, cmd.Long) - assert.NotEmpty(t, cmd.Example) -} - -func TestNewCmdInstall_Alias(t *testing.T) { - ios, _, _, _ := iostreams.Test() - f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} - cmd := NewCmdInstall(f, func(_ *installOptions) error { return nil }) - assert.Contains(t, cmd.Aliases, "add") -} - -func TestNewCmdInstall_Flags(t *testing.T) { - ios, _, _, _ := iostreams.Test() - f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} - cmd := NewCmdInstall(f, func(_ *installOptions) error { return nil }) - - flags := []string{"agent", "scope", "pin", "all", "dir", "force"} - for _, name := range flags { - assert.NotNil(t, cmd.Flags().Lookup(name), "missing flag: --%s", name) - } -} - -func TestNewCmdInstall_MaxArgs(t *testing.T) { - ios, _, _, _ := iostreams.Test() - f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} - cmd := NewCmdInstall(f, func(_ *installOptions) error { return nil }) - - cmd.SetArgs([]string{"a", "b", "c"}) - err := cmd.Execute() - assert.Error(t, err) -} - -func TestResolveRepoArg(t *testing.T) { - tests := []struct { - input string - owner string - repo string - wantErr bool - }{ - {"github/awesome-copilot", "github", "awesome-copilot", false}, - {"owner/repo", "owner", "repo", false}, - {"a/b", "a", "b", false}, - {"https://github.com/owner/repo", "owner", "repo", false}, - {"https://github.com/owner/repo.git", "owner", "repo", false}, - {"invalid", "", "", true}, - {"", "", "", true}, - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - repo, _, err := resolveRepoArg(tt.input, false, nil) - if tt.wantErr { - assert.Error(t, err) - return - } - require.NoError(t, err) - assert.Equal(t, tt.owner, repo.RepoOwner()) - assert.Equal(t, tt.repo, repo.RepoName()) - }) - } -} - -func TestParseSkillFromOpts(t *testing.T) { - tests := []struct { - name string - skillName string - pin string - wantName string - wantVer string - }{ - { - name: "name with version", - skillName: "git-commit@v1.2.0", - wantName: "git-commit", - wantVer: "v1.2.0", - }, - { - name: "name without version", - skillName: "git-commit", - wantName: "git-commit", - wantVer: "", - }, - { - name: "inline version takes precedence over pin", - skillName: "git-commit@v1.0.0", - pin: "v2.0.0", - wantName: "git-commit", - wantVer: "v1.0.0", - }, - { - name: "pin flag alone", - skillName: "git-commit", - pin: "v3.0.0", - wantName: "git-commit", - wantVer: "v3.0.0", - }, - { - name: "empty", - skillName: "", - wantName: "", - wantVer: "", - }, - { - name: "@ at start is not version", - skillName: "@foo", - wantName: "@foo", - wantVer: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - opts := &installOptions{SkillName: tt.skillName, Pin: tt.pin} - parseSkillFromOpts(opts) - assert.Equal(t, tt.wantName, opts.SkillName) - assert.Equal(t, tt.wantVer, opts.version) - }) - } -} - -func TestInstallRun_NonInteractive_NoRepo(t *testing.T) { - ios, _, _, _ := iostreams.Test() - ios.SetStdoutTTY(false) - - opts := &installOptions{ - IO: ios, - GitClient: &git.Client{RepoDir: t.TempDir()}, - } - - err := installRun(opts) - assert.Error(t, err) - assert.Equal(t, "must specify a repository to install from", err.Error()) -} - -func TestInstallRun_NonInteractive_NoSkill(t *testing.T) { - ios, _, _, _ := iostreams.Test() - ios.SetStdoutTTY(false) - - opts := &installOptions{IO: ios, repo: ghrepo.New("o", "r")} - skills := []discovery.Skill{{Name: "test-skill", Path: "skills/test-skill"}} - _, err := selectSkillsWithSelector(opts, skills, false, skillSelector{matchByName: matchSkillByName, sourceHint: "REPO"}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "must specify a skill name or use --all") -} - -func TestSelectSkills_All(t *testing.T) { - ios, _, _, _ := iostreams.Test() - skills := []discovery.Skill{ - {Name: "a"}, - {Name: "b"}, - } - opts := &installOptions{All: true, IO: ios, repo: ghrepo.New("o", "r")} - got, err := selectSkillsWithSelector(opts, skills, false, skillSelector{matchByName: matchSkillByName, sourceHint: "REPO"}) - require.NoError(t, err) - assert.Len(t, got, 2) -} - -func TestSelectSkills_ByName(t *testing.T) { - ios, _, _, _ := iostreams.Test() - skills := []discovery.Skill{ - {Name: "alpha"}, - {Name: "beta"}, - } - opts := &installOptions{SkillName: "beta", IO: ios, repo: ghrepo.New("o", "r")} - got, err := selectSkillsWithSelector(opts, skills, false, skillSelector{matchByName: matchSkillByName, sourceHint: "REPO"}) - require.NoError(t, err) - assert.Len(t, got, 1) - assert.Equal(t, "beta", got[0].Name) -} - -func TestSelectSkills_NotFound(t *testing.T) { - ios, _, _, _ := iostreams.Test() - skills := []discovery.Skill{ - {Name: "alpha"}, - } - opts := &installOptions{SkillName: "nonexistent", IO: ios, repo: ghrepo.New("o", "r")} - _, err := selectSkillsWithSelector(opts, skills, false, skillSelector{matchByName: matchSkillByName, sourceHint: "REPO"}) - assert.Error(t, err) -} - -func TestSkillSearchFunc_EmptyQuery(t *testing.T) { - skills := []discovery.Skill{ - {Name: "alpha", Description: "first skill"}, - {Name: "beta", Description: "second skill"}, - } - fn := skillSearchFunc(skills, 40) - result := fn("") - assert.Nil(t, result.Err) - assert.Len(t, result.Keys, 2) - assert.Equal(t, "alpha", result.Keys[0]) - assert.Equal(t, "beta", result.Keys[1]) - assert.Equal(t, 0, result.MoreResults) -} - -func TestSkillSearchFunc_FilterByName(t *testing.T) { - skills := []discovery.Skill{ - {Name: "git-commit"}, - {Name: "code-review"}, - {Name: "git-push"}, - } - fn := skillSearchFunc(skills, 40) - result := fn("git") - assert.Nil(t, result.Err) - assert.Len(t, result.Keys, 2) - assert.Equal(t, "git-commit", result.Keys[0]) - assert.Equal(t, "git-push", result.Keys[1]) -} - -func TestSkillSearchFunc_FilterByDescription(t *testing.T) { - skills := []discovery.Skill{ - {Name: "alpha", Description: "handles authentication"}, - {Name: "beta", Description: "builds docker images"}, - } - fn := skillSearchFunc(skills, 40) - result := fn("docker") - assert.Nil(t, result.Err) - assert.Len(t, result.Keys, 1) - assert.Equal(t, "beta", result.Keys[0]) -} - -func TestSkillSearchFunc_CaseInsensitive(t *testing.T) { - skills := []discovery.Skill{ - {Name: "Git-Commit"}, - } - fn := skillSearchFunc(skills, 40) - result := fn("GIT") - assert.Nil(t, result.Err) - assert.Len(t, result.Keys, 1) -} - -func TestSkillSearchFunc_MoreResults(t *testing.T) { - skills := make([]discovery.Skill, 50) - for i := range skills { - skills[i] = discovery.Skill{Name: fmt.Sprintf("skill-%d", i)} - } - fn := skillSearchFunc(skills, 40) - result := fn("") - assert.Equal(t, maxSearchResults, len(result.Keys)) - assert.Equal(t, 50-maxSearchResults, result.MoreResults) -} - -func TestMatchSelectedSkills(t *testing.T) { - skills := []discovery.Skill{ - {Name: "alpha"}, - {Name: "beta"}, - {Name: "gamma"}, - } - got, err := matchSelectedSkills(skills, []string{"alpha", "gamma"}) - require.NoError(t, err) - assert.Len(t, got, 2) - assert.Equal(t, "alpha", got[0].Name) - assert.Equal(t, "gamma", got[1].Name) -} - -func TestMatchSelectedSkills_NoMatch(t *testing.T) { - skills := []discovery.Skill{{Name: "alpha"}} - _, err := matchSelectedSkills(skills, []string{"nonexistent"}) - assert.Error(t, err) -} - -func TestResolveHosts_ByFlag(t *testing.T) { - ios, _, _, _ := iostreams.Test() - opts := &installOptions{Agent: "claude-code", IO: ios} - hosts, err := resolveHosts(opts, false) - require.NoError(t, err) - assert.Len(t, hosts, 1) - assert.Equal(t, "claude-code", hosts[0].ID) -} - -func TestResolveHosts_InvalidFlag(t *testing.T) { - ios, _, _, _ := iostreams.Test() - opts := &installOptions{Agent: "nonexistent", IO: ios} - _, err := resolveHosts(opts, false) - assert.Error(t, err) -} - -func TestResolveHosts_DefaultNonInteractive(t *testing.T) { - ios, _, _, _ := iostreams.Test() - opts := &installOptions{IO: ios} - hosts, err := resolveHosts(opts, false) - require.NoError(t, err) - assert.Len(t, hosts, 1) - assert.Equal(t, "github-copilot", hosts[0].ID) -} - -func TestResolveHosts_MultiSelect(t *testing.T) { - ios, _, _, _ := iostreams.Test() - pm := &prompter.PrompterMock{ - MultiSelectFunc: func(_ string, _ []string, _ []string) ([]int, error) { - return []int{0, 1}, nil - }, - } - opts := &installOptions{IO: ios, Prompter: pm} - hosts, err := resolveHosts(opts, true) - require.NoError(t, err) - assert.Len(t, hosts, 2) -} - -func TestResolveHosts_NoneSelected(t *testing.T) { - ios, _, _, _ := iostreams.Test() - pm := &prompter.PrompterMock{ - MultiSelectFunc: func(_ string, _ []string, _ []string) ([]int, error) { - return []int{}, nil - }, - } - opts := &installOptions{IO: ios, Prompter: pm} - _, err := resolveHosts(opts, true) - assert.Error(t, err) -} - -func TestTruncateDescription(t *testing.T) { - tests := []struct { - name string - input string - maxWidth int - }{ - {"short stays short", "A short description", 60}, - {"newlines collapsed", "Line one.\nLine two.\nLine three.", 60}, - {"excessive whitespace", " lots of spaces ", 60}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := truncateDescription(tt.input, tt.maxWidth) - assert.NotContains(t, got, "\n") - }) - } - - long := "Execute git commit with conventional commit message analysis and intelligent staging" - got := truncateDescription(long, 30) - assert.LessOrEqual(t, len(got), 33) // allow room for ellipsis -} - -func TestIsLocalPath(t *testing.T) { - tests := []struct { - arg string - want bool - }{ - {".", true}, - {"./skills", true}, - {"../other", true}, - {"/tmp/skills", true}, - {"~/skills", true}, - {"github/awesome-copilot", false}, - {"owner/repo", false}, - {"", false}, - } - for _, tt := range tests { - t.Run(tt.arg, func(t *testing.T) { - got := isLocalPath(tt.arg) - assert.Equal(t, tt.want, got) - }) - } -} - -func TestIsSkillPath(t *testing.T) { - tests := []struct { - name string - want bool - }{ - {"skills/test-skill", true}, - {"skills/author/skill", true}, - {"plugins/author/skills/skill", true}, - {"skills/author/skill/SKILL.md", true}, - {"git-commit", false}, - {"", false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, isSkillPath(tt.name)) - }) - } -} - -func TestRunLocalInstall_NonInteractive(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "test-local") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - content := "---\nname: test-local\ndescription: A local skill\n---\n# Test\n" - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) - - targetDir := t.TempDir() - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(false) - ios.SetColorEnabled(false) - - opts := &installOptions{ - IO: ios, - SkillSource: dir, - localPath: dir, - All: true, - Force: true, - Agent: "github-copilot", - Scope: "project", - Dir: targetDir, - GitClient: &git.Client{RepoDir: t.TempDir()}, - } - - err := installRun(opts) - require.NoError(t, err) - - assert.Contains(t, stdout.String(), "Installed test-local") - - installed, err := os.ReadFile(filepath.Join(targetDir, "test-local", "SKILL.md")) - require.NoError(t, err) - assert.Contains(t, string(installed), "local-path") -} - -func TestRunLocalInstall_SingleSkillDir(t *testing.T) { - dir := t.TempDir() - content := "---\nname: direct-skill\ndescription: Direct\n---\n# Direct\n" - require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644)) - - targetDir := t.TempDir() - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(false) - ios.SetColorEnabled(false) - - opts := &installOptions{ - IO: ios, - SkillSource: dir, - localPath: dir, - All: true, - Force: true, - Agent: "github-copilot", - Scope: "project", - Dir: targetDir, - GitClient: &git.Client{RepoDir: t.TempDir()}, - } - - err := installRun(opts) - require.NoError(t, err) - - assert.Contains(t, stdout.String(), "Installed direct-skill") -} - -func TestCollisionError(t *testing.T) { - t.Run("no collisions", func(t *testing.T) { - skills := []discovery.Skill{ - {Name: "a"}, - {Name: "b"}, - } - assert.NoError(t, collisionError(skills, "REPO")) - }) - - t.Run("no collisions with different namespaces", func(t *testing.T) { - skills := []discovery.Skill{ - {Name: "xlsx-pro", Namespace: "author1"}, - {Name: "xlsx-pro", Namespace: "author2"}, - } - assert.NoError(t, collisionError(skills, "REPO")) - }) - - t.Run("has collisions same name no namespace", func(t *testing.T) { - skills := []discovery.Skill{ - {Name: "xlsx-pro", Convention: "skills"}, - {Name: "xlsx-pro", Convention: "root"}, - } - err := collisionError(skills, "REPO") - assert.Error(t, err) - assert.Contains(t, err.Error(), "conflicting names") - assert.Contains(t, err.Error(), "gh skills install REPO") - }) - - t.Run("local source hint", func(t *testing.T) { - skills := []discovery.Skill{ - {Name: "xlsx-pro", Convention: "skills"}, - {Name: "xlsx-pro", Convention: "root"}, - } - err := collisionError(skills, "PATH") - assert.Error(t, err) - assert.Contains(t, err.Error(), "conflicting names") - assert.Contains(t, err.Error(), "gh skills install PATH") - }) -} - -func TestMatchSkillByName_Ambiguous(t *testing.T) { - ios, _, _, _ := iostreams.Test() - skills := []discovery.Skill{ - {Name: "xlsx-pro", Namespace: "alice"}, - {Name: "xlsx-pro", Namespace: "bob"}, - } - opts := &installOptions{SkillName: "xlsx-pro", IO: ios, repo: ghrepo.New("o", "r")} - _, err := matchSkillByName(opts, skills) - assert.Error(t, err) - assert.Contains(t, err.Error(), "ambiguous") -} - -func TestMatchSkillByName_NamespacedExact(t *testing.T) { - ios, _, _, _ := iostreams.Test() - skills := []discovery.Skill{ - {Name: "xlsx-pro", Namespace: "alice"}, - {Name: "xlsx-pro", Namespace: "bob"}, - } - opts := &installOptions{SkillName: "bob/xlsx-pro", IO: ios, repo: ghrepo.New("o", "r")} - got, err := matchSkillByName(opts, skills) - require.NoError(t, err) - assert.Len(t, got, 1) - assert.Equal(t, "bob", got[0].Namespace) -} - -func TestFriendlyDir(t *testing.T) { - // Test home directory path - home, err := os.UserHomeDir() - require.NoError(t, err) - got := friendlyDir(filepath.Join(home, ".github", "skills")) - assert.True(t, strings.HasPrefix(got, "~"), "expected ~ prefix, got %q", got) -} - -func TestResolveScope_ExplicitFlag(t *testing.T) { - ios, _, _, _ := iostreams.Test() - opts := &installOptions{ - IO: ios, - Scope: "user", - ScopeChanged: true, - GitClient: &git.Client{RepoDir: t.TempDir()}, - } - scope, err := resolveScope(opts, true) - require.NoError(t, err) - assert.Equal(t, "user", string(scope)) -} - -func TestResolveScope_DirBypasses(t *testing.T) { - ios, _, _, _ := iostreams.Test() - opts := &installOptions{ - IO: ios, - Dir: "/tmp/custom", - Scope: "project", - GitClient: &git.Client{RepoDir: t.TempDir()}, - } - scope, err := resolveScope(opts, true) - require.NoError(t, err) - assert.Equal(t, "project", string(scope)) -} - -func TestCheckOverwrite_NoExisting(t *testing.T) { - ios, _, _, _ := iostreams.Test() - targetDir := t.TempDir() - skills := []discovery.Skill{{Name: "new-skill"}} - host := ®istry.AgentHost{ID: "test", ProjectDir: "skills"} - opts := &installOptions{IO: ios, Dir: targetDir} - - got, err := checkOverwrite(opts, skills, host, registry.ScopeProject, "/tmp", "/home", false) - require.NoError(t, err) - assert.Len(t, got, 1) -} - -func TestCheckOverwrite_ExistingWithForce(t *testing.T) { - targetDir := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "existing-skill"), 0o755)) - - ios, _, _, _ := iostreams.Test() - skills := []discovery.Skill{{Name: "existing-skill"}} - host := ®istry.AgentHost{ID: "test", ProjectDir: "skills"} - opts := &installOptions{IO: ios, Dir: targetDir, Force: true} - - got, err := checkOverwrite(opts, skills, host, registry.ScopeProject, "/tmp", "/home", false) - require.NoError(t, err) - assert.Len(t, got, 1) -} - -func TestCheckOverwrite_ExistingNonInteractive(t *testing.T) { - targetDir := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "existing-skill"), 0o755)) - - ios, _, _, _ := iostreams.Test() - skills := []discovery.Skill{{Name: "existing-skill"}} - host := ®istry.AgentHost{ID: "test", ProjectDir: "skills"} - opts := &installOptions{IO: ios, Dir: targetDir} - - _, err := checkOverwrite(opts, skills, host, registry.ScopeProject, "/tmp", "/home", false) - assert.Error(t, err) - assert.Contains(t, err.Error(), "already installed") -} - func TestNewCmdInstall(t *testing.T) { tests := []struct { - name string - input string - wantOpts installOptions - wantErr bool + name string + cli string + wantOpts installOptions + wantLocalPath bool + wantErr bool }{ { name: "repo argument only", - input: "owner/repo", - wantOpts: installOptions{SkillSource: "owner/repo", Scope: "project"}, + cli: "monalisa/skills-repo", + wantOpts: installOptions{SkillSource: "monalisa/skills-repo", Scope: "project"}, }, { name: "repo and skill", - input: "owner/repo my-skill", - wantOpts: installOptions{SkillSource: "owner/repo", SkillName: "my-skill", Scope: "project"}, + cli: "monalisa/skills-repo git-commit", + wantOpts: installOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Scope: "project"}, }, { - name: "with all flags", - input: "owner/repo my-skill --agent github-copilot --scope user --pin v1.0.0 --force", - wantOpts: installOptions{SkillSource: "owner/repo", SkillName: "my-skill", Agent: "github-copilot", Scope: "user", Pin: "v1.0.0", Force: true}, + name: "all flags", + cli: "monalisa/skills-repo git-commit --agent github-copilot --scope user --pin v1.0.0 --force", + wantOpts: installOptions{ + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "user", + Pin: "v1.0.0", + Force: true, + }, }, { name: "all flag", - input: "owner/repo --all", - wantOpts: installOptions{SkillSource: "owner/repo", All: true, Scope: "project"}, + cli: "monalisa/skills-repo --all", + wantOpts: installOptions{SkillSource: "monalisa/skills-repo", All: true, Scope: "project"}, }, { name: "dir flag", - input: "owner/repo my-skill --dir /tmp/skills", - wantOpts: installOptions{SkillSource: "owner/repo", SkillName: "my-skill", Dir: "/tmp/skills", Scope: "project"}, + cli: "monalisa/skills-repo git-commit --dir ./custom-skills", + wantOpts: installOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Dir: "./custom-skills", Scope: "project"}, }, { name: "too many args", - input: "a b c", + cli: "a b c", wantErr: true, }, + { + name: "invalid agent flag", + cli: "monalisa/skills-repo git-commit --agent nonexistent", + wantErr: true, + }, + { + name: "pin conflicts with inline version", + cli: "monalisa/skills-repo git-commit@v1.0.0 --pin v2.0.0", + wantErr: true, + }, + { + name: "alias add works", + cli: "monalisa/skills-repo git-commit", + wantOpts: installOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Scope: "project"}, + }, + { + name: "dot-slash local path sets localPath", + cli: "./local-dir", + wantOpts: installOptions{SkillSource: "./local-dir", Scope: "project"}, + wantLocalPath: true, + }, + { + name: "absolute path sets localPath", + cli: "/absolute/path", + wantOpts: installOptions{SkillSource: "/absolute/path", Scope: "project"}, + wantLocalPath: true, + }, + { + name: "tilde path sets localPath", + cli: "~/skills", + wantOpts: installOptions{SkillSource: "~/skills", Scope: "project"}, + wantLocalPath: true, + }, + { + name: "owner/repo does not set localPath", + cli: "monalisa/skills-repo", + wantOpts: installOptions{SkillSource: "monalisa/skills-repo", Scope: "project"}, + }, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ios, _, _, _ := iostreams.Test() @@ -664,20 +122,20 @@ func TestNewCmdInstall(t *testing.T) { return nil }) - args, err := shlex.Split(tt.input) + args, err := shlex.Split(tt.cli) require.NoError(t, err) cmd.SetArgs(args) - cmd.SetIn(&strings.Reader{}) - cmd.SetOut(&strings.Builder{}) - cmd.SetErr(&strings.Builder{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) - _, err = cmd.ExecuteC() + err = cmd.Execute() if tt.wantErr { - assert.Error(t, err) + require.Error(t, err) return } require.NoError(t, err) + require.NotNil(t, gotOpts) assert.Equal(t, tt.wantOpts.SkillSource, gotOpts.SkillSource) assert.Equal(t, tt.wantOpts.SkillName, gotOpts.SkillName) assert.Equal(t, tt.wantOpts.Agent, gotOpts.Agent) @@ -686,205 +144,1634 @@ func TestNewCmdInstall(t *testing.T) { assert.Equal(t, tt.wantOpts.Dir, gotOpts.Dir) assert.Equal(t, tt.wantOpts.All, gotOpts.All) assert.Equal(t, tt.wantOpts.Force, gotOpts.Force) + if tt.wantLocalPath { + assert.NotEmpty(t, gotOpts.localPath, "expected localPath to be set") + } else { + assert.Empty(t, gotOpts.localPath, "expected localPath to be empty") + } + }) + } + + // Verify command metadata separately. + t.Run("command metadata", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} + cmd := NewCmdInstall(f, nil) + + assert.Equal(t, "install []", cmd.Use) + assert.NotEmpty(t, cmd.Short) + assert.NotEmpty(t, cmd.Long) + assert.NotEmpty(t, cmd.Example) + assert.Contains(t, cmd.Aliases, "add") + + for _, flag := range []string{"agent", "scope", "pin", "all", "dir", "force"} { + assert.NotNil(t, cmd.Flags().Lookup(flag), "missing flag: --%s", flag) + } + }) +} + +// --- HTTP stub helpers --- + +// stubResolveVersion registers API stubs for latest release + tag resolution. +func stubResolveVersion(reg *httpmock.Registry, owner, repo, tag, sha string) { + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/releases/latest", owner, repo)), + httpmock.StringResponse(fmt.Sprintf(`{"tag_name": %q}`, tag)), + ) + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/git/ref/tags/%s", owner, repo, tag)), + httpmock.StringResponse(fmt.Sprintf(`{"object": {"sha": %q, "type": "commit"}}`, sha)), + ) +} + +// stubDiscoverTree registers the single recursive-tree call used by DiscoverSkills. +func stubDiscoverTree(reg *httpmock.Registry, owner, repo, sha, treeJSON string) { + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/git/trees/%s", owner, repo, sha)), + httpmock.StringResponse(fmt.Sprintf(`{"sha": %q, "tree": [%s]}`, sha, treeJSON)), + ) +} + +// stubInstallFiles registers subtree + blob stubs for installer.Install (one skill). +func stubInstallFiles(reg *httpmock.Registry, owner, repo, treeSHA, blobSHA, content string) { + encoded := base64.StdEncoding.EncodeToString([]byte(content)) + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/git/trees/%s", owner, repo, treeSHA)), + httpmock.StringResponse(fmt.Sprintf(`{"tree": [{"path": "SKILL.md", "type": "blob", "sha": %q, "size": 50}]}`, blobSHA)), + ) + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/git/blobs/%s", owner, repo, blobSHA)), + httpmock.StringResponse(fmt.Sprintf(`{"sha": %q, "content": %q, "encoding": "base64"}`, blobSHA, encoded)), + ) +} + +// stubSkillByPath registers stubs for DiscoverSkillByPath (contents API + tree). +func stubSkillByPath(reg *httpmock.Registry, owner, repo, sha, skillPath, skillName, treeSHA string) { + parentPath := skillPath + if idx := strings.LastIndex(skillPath, "/"); idx >= 0 { + parentPath = skillPath[:idx] + } + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/contents/%s", owner, repo, parentPath)), + httpmock.StringResponse(fmt.Sprintf(`[{"name": %q, "path": %q, "sha": %q, "type": "dir"}]`, skillName, skillPath, treeSHA)), + ) +} + +// writeLocalTestSkill creates a skill directory with a SKILL.md file. +func writeLocalTestSkill(t *testing.T, baseDir, subPath, content string) { + t.Helper() + skillDir := filepath.Join(baseDir, subPath) + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) +} + +// --- Skill content constants --- + +var gitCommitContent = heredoc.Doc(` + --- + name: git-commit + description: Writes commits + --- + # Git Commit +`) + +// singleSkillTreeJSON returns tree entries for a single skill with the given name. +func singleSkillTreeJSON(name, treeSHA, blobSHA string) string { + return fmt.Sprintf( + `{"path": "skills/%s", "type": "tree", "sha": %q}, {"path": "skills/%s/SKILL.md", "type": "blob", "sha": %q}`, + name, treeSHA, name, blobSHA, + ) +} + +func TestInstallRun(t *testing.T) { + tests := []struct { + name string + isTTY bool + setup func(t *testing.T) + stubs func(*httpmock.Registry) + opts func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions + verify func(t *testing.T) + wantErr string + wantStdout string + wantStderr string + }{ + { + name: "non-interactive without repo errors", + isTTY: false, + opts: func(ios *iostreams.IOStreams, _ *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantErr: "must specify a repository to install from", + }, + { + name: "non-interactive without skill name errors", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + } + }, + wantErr: "must specify a skill name or use --all", + }, + { + name: "remote install writes files with tracking metadata", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install with --all installs multiple skills", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + treeJSON := fmt.Sprintf("%s, %s", + singleSkillTreeJSON("code-review", "tree0", "blob0"), + singleSkillTreeJSON("git-commit", "tree1", "blob1")) + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) + stubInstallFiles(reg, "monalisa", "skills-repo", "tree0", "blob0", + "---\nname: code-review\ndescription: Reviews\n---\n# B\n") + stubInstallFiles(reg, "monalisa", "skills-repo", "tree1", "blob1", + "---\nname: git-commit\ndescription: Commits\n---\n# A\n") + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + All: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed", + }, + { + name: "remote install with --agent claude-code", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "claude-code", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install defaults to github-copilot non-interactively", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install with --scope user", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "user", + ScopeChanged: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install with --dir bypasses scope resolution", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install with --force overwrites existing skill", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + targetDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "git-commit"), 0o755)) + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + Force: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install existing skill without force non-interactive errors", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + targetDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "git-commit"), 0o755)) + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + } + }, + wantErr: "already installed", + }, + { + name: "remote install skill not found errors", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "nonexistent", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantErr: `skill "nonexistent" not found`, + }, + { + name: "remote install ambiguous skill name errors", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + // Two namespaced skills with the same name + treeJSON := `{"path": "skills/alice", "type": "tree", "sha": "nsA"}, ` + + `{"path": "skills/alice/xlsx-pro", "type": "tree", "sha": "treeA"}, ` + + `{"path": "skills/alice/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobA"}, ` + + `{"path": "skills/bob", "type": "tree", "sha": "nsB"}, ` + + `{"path": "skills/bob/xlsx-pro", "type": "tree", "sha": "treeB"}, ` + + `{"path": "skills/bob/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobB"}` + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "xlsx-pro", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantErr: "ambiguous", + }, + { + name: "remote install namespaced exact match resolves ambiguity", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + treeJSON := `{"path": "skills/alice", "type": "tree", "sha": "nsA"}, ` + + `{"path": "skills/alice/xlsx-pro", "type": "tree", "sha": "treeA"}, ` + + `{"path": "skills/alice/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobA"}, ` + + `{"path": "skills/bob", "type": "tree", "sha": "nsB"}, ` + + `{"path": "skills/bob/xlsx-pro", "type": "tree", "sha": "treeB"}, ` + + `{"path": "skills/bob/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobB"}` + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeB", "blobB", + "---\nname: xlsx-pro\ndescription: Bob version\n---\n# B\n") + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "bob/xlsx-pro", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed bob/xlsx-pro", + }, + { + name: "remote install with invalid repo argument errors", + isTTY: false, + opts: func(ios *iostreams.IOStreams, _ *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "invalid", + SkillName: "git-commit", + } + }, + wantErr: "invalid repository reference", + }, + { + name: "remote install with pin flag resolves version", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/tags/v2.0.0"), + httpmock.StringResponse(`{"object": {"sha": "def456", "type": "commit"}}`), + ) + stubDiscoverTree(reg, "monalisa", "skills-repo", "def456", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Pin: "v2.0.0", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + wantStderr: "v2.0.0", + }, + { + name: "remote install outputs review hint", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + wantStderr: "prompt injections or malicious scripts", + }, + { + name: "remote install outputs file tree for TTY", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "SKILL.md", + }, + { + name: "remote install with inline version parses name and version", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/tags/v1.2.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit@v1.2.0", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + wantStderr: "v1.2.0", + }, + { + name: "remote install by skill path skips full discovery", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubSkillByPath(reg, "monalisa", "skills-repo", "abc123", "skills/git-commit", "git-commit", "treeSHA") + // DiscoverSkillByPath: tree + blob (for fetchDescription) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + // installer.Install: tree + blob (again, for writing files) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "skills/git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install with URL repo argument", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "https://github.com/monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install all with collisions errors", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + // Two skills with the same install name: skills/xlsx-pro and root xlsx-pro + treeJSON := `{"path": "skills/xlsx-pro", "type": "tree", "sha": "tree0"}, ` + + `{"path": "skills/xlsx-pro/SKILL.md", "type": "blob", "sha": "blob0"}, ` + + `{"path": "xlsx-pro", "type": "tree", "sha": "tree1"}, ` + + `{"path": "xlsx-pro/SKILL.md", "type": "blob", "sha": "blob1"}` + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + All: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantErr: "conflicting names", + }, + { + name: "remote install all with namespaced skills avoids collisions", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + treeJSON := `{"path": "skills/alice", "type": "tree", "sha": "nsA"}, ` + + `{"path": "skills/alice/xlsx-pro", "type": "tree", "sha": "treeA"}, ` + + `{"path": "skills/alice/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobA"}, ` + + `{"path": "skills/bob", "type": "tree", "sha": "nsB"}, ` + + `{"path": "skills/bob/xlsx-pro", "type": "tree", "sha": "treeB"}, ` + + `{"path": "skills/bob/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobB"}` + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeA", "blobA", + "---\nname: xlsx-pro\ndescription: Alice\n---\n# A\n") + stubInstallFiles(reg, "monalisa", "skills-repo", "treeB", "blobB", + "---\nname: xlsx-pro\ndescription: Bob\n---\n# B\n") + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + All: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed", + }, + { + name: "remote install friendlyDir shows tilde for home paths", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "user", + ScopeChanged: true, + } + }, + wantStdout: "~", + }, + { + name: "interactive skill selection via prompt", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + // 31 skills to exercise maxSearchResults cap + one without description + var treeEntries []string + for i := range 31 { + name := fmt.Sprintf("skill-%02d", i) + treeEntries = append(treeEntries, + fmt.Sprintf(`{"path": "skills/%s", "type": "tree", "sha": "tree-%s"}`, name, name), + fmt.Sprintf(`{"path": "skills/%s/SKILL.md", "type": "blob", "sha": "blob-%s"}`, name, name)) + } + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + strings.Join(treeEntries, ", ")) + // Blob stubs for FetchDescriptionsConcurrent (one per skill) + for i := range 31 { + name := fmt.Sprintf("skill-%02d", i) + blobSHA := fmt.Sprintf("blob-%s", name) + var content string + if i == 0 { + // First skill has no description (exercises else branch in label building) + content = fmt.Sprintf("---\nname: %s\n---\n# Skill\n", name) + } else { + content = fmt.Sprintf("---\nname: %s\ndescription: Does %s things\n---\n# Skill\n", name, name) + } + encoded := base64.StdEncoding.EncodeToString([]byte(content)) + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/monalisa/octocat-skills/git/blobs/%s", blobSHA)), + httpmock.StringResponse(fmt.Sprintf(`{"sha": %q, "content": %q, "encoding": "base64"}`, blobSHA, encoded))) + } + // Install stubs for the selected skill (skill-01) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-skill-01", "blob-skill-01", + "---\nname: skill-01\ndescription: Does skill-01 things\n---\n# Skill\n") + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + pm := &prompter.PrompterMock{ + MultiSelectWithSearchFunc: func(prompt, searchPrompt string, defaults, persistentOptions []string, searchFunc func(string) prompter.MultiSelectSearchResult) ([]string, error) { + // Exercise searchFunc: empty query hits maxSearchResults cap (31 > 30) + all := searchFunc("") + if all.MoreResults == 0 { + return nil, fmt.Errorf("expected MoreResults > 0 for 31 skills") + } + // Non-empty query filters down + filtered := searchFunc("skill-01") + if len(filtered.Keys) == 0 { + return nil, fmt.Errorf("search returned no results") + } + return []string{filtered.Keys[0]}, nil + }, + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil + }, + } + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + Agent: "github-copilot", + Force: true, + } + }, + wantStdout: "Installed skill-01", + }, + { + name: "interactive scope prompt", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + pm := &prompter.PrompterMock{ + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil + }, + } + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Agent: "github-copilot", + Force: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "interactive overwrite confirmation declined", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + destDir := t.TempDir() + writeLocalTestSkill(t, destDir, "git-commit", gitCommitContent) + pm := &prompter.PrompterMock{ + ConfirmFunc: func(prompt string, defaultValue bool) (bool, error) { + return false, nil + }, + } + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: destDir, + } + }, + wantStderr: "No skills to install", + }, + { + name: "interactive host selection via MultiSelect", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + Prompter: &prompter.PrompterMock{ + MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) { + return []int{0}, nil // select first agent + }, + SelectFunc: func(prompt string, defaultValue string, options []string) (int, error) { + return 0, nil // project scope + }, + }, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Force: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "scope prompt uses Remotes for repo name", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + pm := &prompter.PrompterMock{ + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil // project scope + }, + } + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + Remotes: func() (context.Remotes, error) { + return context.Remotes{ + {Remote: &git.Remote{Name: "origin"}, Repo: ghrepo.New("monalisa", "octocat-skills")}, + }, nil + }, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Agent: "github-copilot", + Force: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "interactive overwrite shows source info", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + destDir := t.TempDir() + existingContent := heredoc.Doc(` + --- + name: git-commit + description: Writes commits + metadata: + github-owner: someowner + github-repo: somerepo + github-ref: v0.5.0 + --- + # Git Commit + `) + writeLocalTestSkill(t, destDir, "git-commit", existingContent) + pm := &prompter.PrompterMock{ + ConfirmFunc: func(prompt string, defaultValue bool) (bool, error) { + assert.Contains(t, prompt, "someowner/somerepo@v0.5.0") + return true, nil + }, + } + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: destDir, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "select all skills in interactive prompt", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + // Blob stub for FetchDescriptionsConcurrent + encoded := base64.StdEncoding.EncodeToString([]byte(gitCommitContent)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-gc"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blob-gc", "content": %q, "encoding": "base64"}`, encoded))) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + pm := &prompter.PrompterMock{ + MultiSelectWithSearchFunc: func(prompt, searchPrompt string, defaults, persistentOptions []string, searchFunc func(string) prompter.MultiSelectSearchResult) ([]string, error) { + return []string{"(all skills)"}, nil + }, + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil + }, + } + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + Agent: "github-copilot", + Force: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "interactive repo prompt via Input", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + pm := &prompter.PrompterMock{ + InputFunc: func(prompt, defaultValue string) (string, error) { + return "monalisa/octocat-skills", nil + }, + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil + }, + } + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillName: "git-commit", + Agent: "github-copilot", + Force: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "interactive scope prompt selects user scope", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + pm := &prompter.PrompterMock{ + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 1, nil // user scope + }, + } + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Agent: "github-copilot", + Force: true, + } + }, + wantStdout: "~", + }, + { + name: "interactive overwrite without metadata shows plain prompt", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + destDir := t.TempDir() + // Existing skill without github metadata in frontmatter + writeLocalTestSkill(t, destDir, "git-commit", heredoc.Doc(` + --- + name: git-commit + description: No metadata + --- + # Git Commit + `)) + pm := &prompter.PrompterMock{ + ConfirmFunc: func(prompt string, defaultValue bool) (bool, error) { + assert.Contains(t, prompt, "already exists") + assert.NotContains(t, prompt, "installed from") + return true, nil + }, + } + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: destDir, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install single exact match by name", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + treeJSON := `{"path": "skills/alice", "type": "tree", "sha": "nsA"}, ` + + `{"path": "skills/alice/xlsx-pro", "type": "tree", "sha": "treeA"}, ` + + `{"path": "skills/alice/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobA"}, ` + + `{"path": "skills/git-commit", "type": "tree", "sha": "treeGC"}, ` + + `{"path": "skills/git-commit/SKILL.md", "type": "blob", "sha": "blobGC"}` + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeGC", "blobGC", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "multi-host install outputs per-host headers", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + // Two install rounds (one per host) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + pm := &prompter.PrompterMock{ + MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) { + return []int{0, 1}, nil // select two agents + }, + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil // project scope + }, + } + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Force: true, + } + }, + wantStdout: "Installed git-commit", + wantStderr: "Installing to", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + + if tt.stubs != nil { + tt.stubs(reg) + } + if tt.setup != nil { + tt.setup(t) + } + + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(tt.isTTY) + ios.SetStdinTTY(tt.isTTY) + ios.SetStderrTTY(tt.isTTY) + opts := tt.opts(ios, reg) + + err := installRun(opts) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + if tt.wantStdout != "" { + assert.Contains(t, stdout.String(), tt.wantStdout) + } + if tt.wantStderr != "" { + assert.Contains(t, stderr.String(), tt.wantStderr) + } + if tt.verify != nil { + tt.verify(t) + } }) } } -func TestInstallRun_RemoteInstall(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - skillContent := "---\nname: test-skill\ndescription: A test\n---\n# Test\n" - encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent)) - - reg := &httpmock.Registry{} - reg.Register( - httpmock.REST("GET", "repos/owner/repo/releases/latest"), - httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), - ) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), - httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), - ) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), - httpmock.StringResponse(`{"sha": "abc123", "tree": [{"path": "skills/test-skill", "type": "tree", "sha": "treeSHA"}, {"path": "skills/test-skill/SKILL.md", "type": "blob", "sha": "blobSHA"}]}`), - ) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA"), - httpmock.StringResponse(`{"tree": [{"path": "SKILL.md", "type": "blob", "sha": "blobSHA", "size": 50}]}`), - ) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/git/blobs/blobSHA"), - httpmock.StringResponse(fmt.Sprintf(`{"sha": "blobSHA", "content": "%s", "encoding": "base64"}`, encodedContent)), - ) - - targetDir := t.TempDir() - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetColorEnabled(false) - - opts := &installOptions{ - IO: ios, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil +func TestRunLocalInstall(t *testing.T) { + tests := []struct { + name string + isTTY bool + setup func(t *testing.T, sourceDir, targetDir string) + opts func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions + verify func(t *testing.T, targetDir string) + wantErr string + wantStdout string + wantStderr string + }{ + { + name: "installs skill with local-path metadata", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + All: true, + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + verify: func(t *testing.T, targetDir string) { + t.Helper() + data, err := os.ReadFile(filepath.Join(targetDir, "git-commit", "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(data), "local-path") + }, + wantStdout: "Installed git-commit", + }, + { + name: "single skill directory (SKILL.md at root)", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + content := heredoc.Doc(` + --- + name: direct-skill + description: Direct + --- + # Direct + `) + require.NoError(t, os.WriteFile(filepath.Join(sourceDir, "SKILL.md"), []byte(content), 0o644)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + All: true, + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "Installed direct-skill", + }, + { + name: "namespaced skills install to separate directories", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + for _, ns := range []string{"alice", "bob"} { + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", ns, "xlsx-pro"), + fmt.Sprintf("---\nname: xlsx-pro\ndescription: %s xlsx-pro\n---\n# Test\n", ns)) + } + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + All: true, + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + verify: func(t *testing.T, targetDir string) { + t.Helper() + _, err := os.Stat(filepath.Join(targetDir, "alice", "xlsx-pro", "SKILL.md")) + assert.NoError(t, err, "alice/xlsx-pro should be installed") + _, err = os.Stat(filepath.Join(targetDir, "bob", "xlsx-pro", "SKILL.md")) + assert.NoError(t, err, "bob/xlsx-pro should be installed") + }, + wantStdout: "Installed alice/xlsx-pro", + }, + { + name: "local install with --force overwrites namespaced skill", + isTTY: false, + setup: func(t *testing.T, sourceDir, targetDir string) { + t.Helper() + for _, ns := range []string{"alice", "bob"} { + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", ns, "xlsx-pro"), + fmt.Sprintf("---\nname: xlsx-pro\ndescription: %s xlsx-pro\n---\n# Test\n", ns)) + } + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "alice", "xlsx-pro"), 0o755)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + All: true, + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "Installed", + }, + { + name: "local install existing skill without force non-interactive errors", + isTTY: false, + setup: func(t *testing.T, sourceDir, targetDir string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "git-commit"), 0o755)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantErr: "already installed", + }, + { + name: "local install with no skills found errors", + isTTY: false, + setup: func(_ *testing.T, _, _ string) {}, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + All: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantErr: "no skills found", + }, + { + name: "local install outputs review hint", + isTTY: true, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + All: true, + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStderr: "Review the installed files before use", + }, + { + name: "local install with --agent claude-code", + isTTY: true, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + All: true, + Force: true, + Agent: "claude-code", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "local install by skill name selects one", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "code-review"), heredoc.Doc(` + --- + name: code-review + description: Reviews code + --- + # Code Review + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "git-commit", + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "local install outputs file tree for TTY", + isTTY: true, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + skillDir := filepath.Join(sourceDir, "skills", "git-commit") + require.NoError(t, os.MkdirAll(filepath.Join(skillDir, "scripts"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), + []byte("---\nname: git-commit\ndescription: Commits\n---\n# A\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "scripts", "run.sh"), + []byte("#!/bin/bash"), 0o644)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + All: true, + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "SKILL.md", + }, + { + name: "local path with tilde expansion", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + t.Setenv("HOME", sourceDir) + t.Setenv("USERPROFILE", sourceDir) + return &installOptions{ + IO: ios, + SkillSource: "~/", + localPath: "~/", + All: true, + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "local path with bare tilde expansion", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + t.Setenv("HOME", sourceDir) + t.Setenv("USERPROFILE", sourceDir) + return &installOptions{ + IO: ios, + SkillSource: "~", + localPath: "~", + All: true, + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "local skill not found by name", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "nonexistent-skill", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantErr: "not found in local directory", }, - GitClient: &git.Client{RepoDir: t.TempDir()}, - SkillSource: "owner/repo", - SkillName: "test-skill", - Agent: "github-copilot", - Scope: "project", - Dir: targetDir, } - defer reg.Verify(t) - err := installRun(opts) - require.NoError(t, err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) - assert.Contains(t, stdout.String(), "Installed test-skill") + sourceDir := t.TempDir() + targetDir := t.TempDir() - installed, readErr := os.ReadFile(filepath.Join(targetDir, "test-skill", "SKILL.md")) - require.NoError(t, readErr) - assert.Contains(t, string(installed), "github-owner: owner") - assert.Contains(t, string(installed), "github-repo: repo") -} + if tt.setup != nil { + tt.setup(t, sourceDir, targetDir) + } -func TestPrintFileTree(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "my-skill") - require.NoError(t, os.MkdirAll(filepath.Join(skillDir, "scripts"), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# test"), 0o644)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "scripts", "run.sh"), []byte("#!/bin/bash"), 0o644)) + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(tt.isTTY) + ios.SetStdinTTY(tt.isTTY) + ios.SetStderrTTY(tt.isTTY) + opts := tt.opts(ios, sourceDir, targetDir) - ios, _, stdout, _ := iostreams.Test() - cs := ios.ColorScheme() + err := installRun(opts) - printFileTree(stdout, cs, dir, []string{"my-skill"}) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } - out := stdout.String() - assert.Contains(t, out, "my-skill/") - assert.Contains(t, out, "SKILL.md") - assert.Contains(t, out, "scripts/") - assert.Contains(t, out, "run.sh") -} - -func TestPrintFileTree_Empty(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - cs := ios.ColorScheme() - - printFileTree(stdout, cs, t.TempDir(), nil) - assert.Empty(t, stdout.String()) -} - -func TestPrintTreeDir_Unreadable(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - cs := ios.ColorScheme() - - printTreeDir(stdout, cs, filepath.Join(t.TempDir(), "nonexistent"), " ") - assert.Contains(t, stdout.String(), "(could not read directory)") -} - -func TestPrintReviewHint_Remote(t *testing.T) { - ios, _, _, stderr := iostreams.Test() - cs := ios.ColorScheme() - - printReviewHint(stderr, cs, "owner/repo", []string{"my-skill", "other-skill"}) - - out := stderr.String() - assert.Contains(t, out, "prompt injections or malicious scripts") - assert.Contains(t, out, "gh skills preview owner/repo my-skill") - assert.Contains(t, out, "gh skills preview owner/repo other-skill") -} - -func TestPrintReviewHint_Local(t *testing.T) { - ios, _, _, stderr := iostreams.Test() - cs := ios.ColorScheme() - - printReviewHint(stderr, cs, "", []string{"my-skill"}) - - out := stderr.String() - assert.Contains(t, out, "prompt injections or malicious scripts") - assert.Contains(t, out, "Review the installed files before use.") - assert.NotContains(t, out, "gh skills preview") -} - -func TestPrintReviewHint_Empty(t *testing.T) { - ios, _, _, stderr := iostreams.Test() - cs := ios.ColorScheme() - - printReviewHint(stderr, cs, "owner/repo", nil) - assert.Empty(t, stderr.String()) -} - -func TestSelectSkills_AllWithNamespacedSkills(t *testing.T) { - ios, _, _, _ := iostreams.Test() - skills := []discovery.Skill{ - {Name: "xlsx-pro", Namespace: "alice", Convention: "skills-namespaced"}, - {Name: "xlsx-pro", Namespace: "bob", Convention: "skills-namespaced"}, - {Name: "other-skill", Convention: "skills"}, + require.NoError(t, err) + if tt.wantStdout != "" { + assert.Contains(t, stdout.String(), tt.wantStdout) + } + if tt.wantStderr != "" { + assert.Contains(t, stderr.String(), tt.wantStderr) + } + if tt.verify != nil { + tt.verify(t, targetDir) + } + }) } - opts := &installOptions{All: true, IO: ios, repo: ghrepo.New("o", "r")} - got, err := selectSkillsWithSelector(opts, skills, false, skillSelector{matchByName: matchSkillByName, sourceHint: "REPO"}) - require.NoError(t, err) - assert.Len(t, got, 3) -} - -func TestRunLocalInstall_NamespacedSkills(t *testing.T) { - dir := t.TempDir() - - // Create two skills with the same name under different namespaces - for _, ns := range []string{"alice", "bob"} { - skillDir := filepath.Join(dir, "skills", ns, "xlsx-pro") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - content := fmt.Sprintf("---\nname: xlsx-pro\ndescription: %s xlsx-pro\n---\n# Test\n", ns) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) - } - - targetDir := t.TempDir() - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(false) - ios.SetColorEnabled(false) - - opts := &installOptions{ - IO: ios, - SkillSource: dir, - localPath: dir, - All: true, - Force: true, - Agent: "github-copilot", - Scope: "project", - Dir: targetDir, - GitClient: &git.Client{RepoDir: t.TempDir()}, - } - - err := installRun(opts) - require.NoError(t, err) - - out := stdout.String() - assert.Contains(t, out, "Installed alice/xlsx-pro") - assert.Contains(t, out, "Installed bob/xlsx-pro") - - // Both should be installed in separate directories - _, err = os.Stat(filepath.Join(targetDir, "alice", "xlsx-pro", "SKILL.md")) - assert.NoError(t, err, "alice/xlsx-pro should be installed") - _, err = os.Stat(filepath.Join(targetDir, "bob", "xlsx-pro", "SKILL.md")) - assert.NoError(t, err, "bob/xlsx-pro should be installed") -} - -func TestCheckOverwrite_NamespacedSkill(t *testing.T) { - ios, _, _, _ := iostreams.Test() - targetDir := t.TempDir() - - // Pre-create a namespaced skill directory - require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "alice", "xlsx-pro"), 0o755)) - - skills := []discovery.Skill{ - {Name: "xlsx-pro", Namespace: "alice"}, - {Name: "xlsx-pro", Namespace: "bob"}, - } - host := ®istry.AgentHost{ID: "test", ProjectDir: "skills"} - opts := &installOptions{IO: ios, Dir: targetDir, Force: true} - - got, err := checkOverwrite(opts, skills, host, registry.ScopeProject, "/tmp", "/home", false) - require.NoError(t, err) - assert.Len(t, got, 2, "both skills should be installable (force mode)") } diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index 9541f4230..f06d4c182 100644 --- a/pkg/cmd/skills/preview/preview.go +++ b/pkg/cmd/skills/preview/preview.go @@ -199,16 +199,16 @@ func renderAllFiles(opts *previewOptions, cs *iostreams.ColorScheme, skill disco totalBytes := 0 for _, f := range extraFiles { if fetched >= maxFiles { - fmt.Fprintf(out, "\n%s\n", cs.Gray(fmt.Sprintf("(skipped remaining files — showing first %d)", maxFiles))) + fmt.Fprintf(out, "\n%s\n", cs.Muted(fmt.Sprintf("(skipped remaining files — showing first %d)", maxFiles))) break } if totalBytes+f.Size > maxTotalBytes && fetched > 0 { - fmt.Fprintf(out, "\n%s\n", cs.Gray("(skipped remaining files — size limit reached)")) + fmt.Fprintf(out, "\n%s\n", cs.Muted("(skipped remaining files — size limit reached)")) break } fileContent, fetchErr := discovery.FetchBlob(apiClient, hostname, owner, repo, f.SHA) if fetchErr != nil { - fmt.Fprintf(out, "\n%s\n\n%s\n", cs.Bold("── "+f.Path+" ──"), cs.Gray("(could not fetch file)")) + fmt.Fprintf(out, "\n%s\n\n%s\n", cs.Bold("── "+f.Path+" ──"), cs.Muted("(could not fetch file)")) continue } fetched++ @@ -373,10 +373,10 @@ func printTree(w io.Writer, cs *iostreams.ColorScheme, nodes []*treeNode, indent childIndent = " " } if node.isDir { - fmt.Fprintf(w, "%s%s%s\n", indent, cs.Gray(connector), cs.Bold(node.name+"/")) - printTree(w, cs, node.children, indent+cs.Gray(childIndent)) + fmt.Fprintf(w, "%s%s%s\n", indent, cs.Muted(connector), cs.Bold(node.name+"/")) + printTree(w, cs, node.children, indent+cs.Muted(childIndent)) } else { - fmt.Fprintf(w, "%s%s%s\n", indent, cs.Gray(connector), node.name) + fmt.Fprintf(w, "%s%s%s\n", indent, cs.Muted(connector), node.name) } } } diff --git a/pkg/cmd/skills/preview/preview_test.go b/pkg/cmd/skills/preview/preview_test.go index b77455828..4c3864809 100644 --- a/pkg/cmd/skills/preview/preview_test.go +++ b/pkg/cmd/skills/preview/preview_test.go @@ -3,9 +3,12 @@ package preview import ( "encoding/base64" "fmt" + "io" "net/http" + "strings" "testing" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmdutil" @@ -13,6 +16,7 @@ import ( "github.com/cli/cli/v2/pkg/iostreams" "github.com/google/shlex" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestNewCmdPreview(t *testing.T) { @@ -62,31 +66,32 @@ func TestNewCmdPreview(t *testing.T) { args, _ := shlex.Split(tt.input) cmd.SetArgs(args) - cmd.SetOut(&discardWriter{}) - cmd.SetErr(&discardWriter{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) err := cmd.Execute() if tt.wantErr { - assert.Error(t, err) + require.Error(t, err) return } - assert.NoError(t, err) + require.NoError(t, err) assert.Equal(t, tt.wantRepo, gotOpts.RepoArg) assert.Equal(t, tt.wantSkillName, gotOpts.SkillName) }) } } -func TestNewCmdPreview_Alias(t *testing.T) { - ios, _, _, _ := iostreams.Test() - f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}} - cmd := NewCmdPreview(f, func(_ *previewOptions) error { return nil }) - assert.Contains(t, cmd.Aliases, "show") -} - func TestPreviewRun(t *testing.T) { - skillContent := "---\nname: my-skill\ndescription: A test skill\n---\n# My Skill\n\nThis is the skill content." + skillContent := heredoc.Doc(` + --- + name: my-skill + description: A test skill + --- + # My Skill + + This is the skill content. + `) encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent)) tests := []struct { @@ -266,11 +271,11 @@ func TestPreviewRun(t *testing.T) { err := previewRun(tt.opts) if tt.wantErr != "" { - assert.EqualError(t, err, tt.wantErr) + require.EqualError(t, err, tt.wantErr) return } - assert.NoError(t, err) + require.NoError(t, err) if tt.wantStdout != "" { assert.Contains(t, stdout.String(), tt.wantStdout) } @@ -338,12 +343,19 @@ func TestPreviewRun_Interactive(t *testing.T) { } err := previewRun(opts) - assert.NoError(t, err) + require.NoError(t, err) assert.Contains(t, stdout.String(), "Selected Skill") } func TestPreviewRun_ShowsFileTree(t *testing.T) { - skillContent := "---\nname: my-skill\ndescription: test\n---\n# My Skill\nBody." + skillContent := heredoc.Doc(` + --- + name: my-skill + description: test + --- + # My Skill + Body. + `) encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent)) scriptContent := "#!/bin/bash\necho hello" @@ -426,7 +438,7 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { } err := previewRun(opts) - assert.NoError(t, err) + require.NoError(t, err) out := stdout.String() assert.Contains(t, out, "echo hello") @@ -450,7 +462,7 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { } err := previewRun(opts) - assert.NoError(t, err) + require.NoError(t, err) out := stdout.String() assert.Contains(t, out, "my-skill/") @@ -460,7 +472,174 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { }) } -// discardWriter is a no-op writer for suppressing cobra output in tests. -type discardWriter struct{} +func TestPreviewRun_RenderLimits(t *testing.T) { + skillContent := heredoc.Doc(` + --- + name: my-skill + description: test + --- + # My Skill + `) + encodedSkill := base64.StdEncoding.EncodeToString([]byte(skillContent)) -func (d *discardWriter) Write(p []byte) (int, error) { return len(p), nil } + // Helper: build a tree JSON with N extra files (beyond SKILL.md) + buildTree := func(n int) string { + entries := []string{ + `{"path": "skills/my-skill", "type": "tree", "sha": "treeSHA"}`, + `{"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobSKILL"}`, + } + for i := range n { + entries = append(entries, fmt.Sprintf( + `{"path": "skills/my-skill/file%03d.txt", "type": "blob", "sha": "blob%03d"}`, i, i)) + } + return fmt.Sprintf(`{"sha":"abc123","truncated":false,"tree":[%s]}`, + strings.Join(entries, ",")) + } + + // Helper: build subtree JSON with N extra files + buildSubtree := func(n int, sizes []int) string { + entries := []string{ + `{"path": "SKILL.md", "type": "blob", "sha": "blobSKILL", "size": 50}`, + } + for i := range n { + sz := 10 + if i < len(sizes) { + sz = sizes[i] + } + entries = append(entries, fmt.Sprintf( + `{"path": "file%03d.txt", "type": "blob", "sha": "blob%03d", "size": %d}`, i, i, sz)) + } + return fmt.Sprintf(`{"tree":[%s]}`, strings.Join(entries, ",")) + } + + // Common stubs for resolve + discover + registerBase := func(reg *httpmock.Registry, treeJSON, subtreeJSON string) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/trees/abc123"), + httpmock.StringResponse(treeJSON), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/trees/treeSHA"), + httpmock.StringResponse(subtreeJSON), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blobSKILL"), + httpmock.StringResponse(`{"sha": "blobSKILL", "content": "`+encodedSkill+`", "encoding": "base64"}`), + ) + } + + t.Run("maxFiles cap truncates at 20", func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + n := 22 + treeJSON := buildTree(n) + subtreeJSON := buildSubtree(n, nil) + registerBase(reg, treeJSON, subtreeJSON) + + // Register blob stubs for files 0-19 (first 20 get fetched) + tinyContent := base64.StdEncoding.EncodeToString([]byte("tiny")) + for i := range 20 { + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/monalisa/skills-repo/git/blobs/blob%03d", i)), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blob%03d", "content": "%s", "encoding": "base64"}`, i, tinyContent)), + ) + } + // Files 20 and 21 should NOT be fetched + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + + opts := &previewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: &prompter.PrompterMock{}, + repo: ghrepo.New("monalisa", "skills-repo"), + SkillName: "my-skill", + } + + err := previewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "showing first 20") + assert.Contains(t, out, "file019.txt") // last fetched + }) + + t.Run("maxBytes cap stops fetching", func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + // Two files: first is 500KB, second would exceed 512KB cap + sizes := []int{500 * 1024, 100 * 1024} + treeJSON := buildTree(2) + subtreeJSON := buildSubtree(2, sizes) + registerBase(reg, treeJSON, subtreeJSON) + + bigContent := base64.StdEncoding.EncodeToString(make([]byte, 500*1024)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blob000"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blob000", "content": "%s", "encoding": "base64"}`, bigContent)), + ) + // blob001 should NOT be fetched — size limit reached + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + + opts := &previewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: &prompter.PrompterMock{}, + repo: ghrepo.New("monalisa", "skills-repo"), + SkillName: "my-skill", + } + + err := previewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "size limit reached") + }) + + t.Run("blob fetch error shows fallback message", func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + treeJSON := buildTree(1) + subtreeJSON := buildSubtree(1, nil) + registerBase(reg, treeJSON, subtreeJSON) + + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blob000"), + httpmock.StatusStringResponse(500, "server error"), + ) + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + + opts := &previewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: &prompter.PrompterMock{}, + repo: ghrepo.New("monalisa", "skills-repo"), + SkillName: "my-skill", + } + + err := previewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "could not fetch file") + }) +} diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index 9a9200131..200e3e2dd 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -15,7 +15,6 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/git" - giturl "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" @@ -40,10 +39,9 @@ type publishOptions struct { Dir string // directory to validate (default: cwd) // Flags - Fix bool // --fix flag: auto-fix issues where possible - Plugins bool // --plugins flag: generate .claude-plugin/ manifest - DryRun bool // --dry-run flag: validate only, don't publish - Tag string // --tag flag: release tag to create + Fix bool // --fix flag: auto-fix issues where possible + DryRun bool // --dry-run flag: validate only, don't publish + Tag string // --tag flag: release tag to create // Testing overrides client *api.Client // injectable for tests; nil means use factory HttpClient @@ -126,8 +124,6 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*publishOptions) error) *cobra. Use --dry-run to validate without publishing. Use --tag to publish non-interactively with a specific tag. Use --fix to automatically strip install metadata from committed files. - Use --plugins to generate a .claude-plugin/plugin.json manifest for - Claude Code plugin discovery. `), Example: heredoc.Doc(` # Validate and publish interactively @@ -141,9 +137,6 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*publishOptions) error) *cobra. # Validate and strip install metadata $ gh skills publish --fix - - # Generate Claude Code plugin manifest - $ gh skills publish --plugins `), Aliases: []string{"validate"}, Args: cobra.MaximumNArgs(1), @@ -159,7 +152,6 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*publishOptions) error) *cobra. } cmd.Flags().BoolVar(&opts.Fix, "fix", false, "Auto-fix issues where possible (e.g. strip install metadata)") - cmd.Flags().BoolVar(&opts.Plugins, "plugins", false, "Generate .claude-plugin/ manifest for Claude Code plugin discovery") cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Validate without publishing") cmd.Flags().StringVar(&opts.Tag, "tag", "", "Version tag for the release (e.g. v1.0.0)") @@ -181,7 +173,6 @@ func publishRun(opts *publishOptions) error { return fmt.Errorf("could not resolve path: %w", err) } - cs := opts.IO.ColorScheme() canPrompt := opts.IO.CanPrompt() // Use injected client or create one from the factory HttpClient @@ -405,19 +396,6 @@ func publishRun(opts *publishOptions) error { renderDiagnosticsPlain(opts, diagnostics, errors, warnings) } - // Generate Claude Code plugin manifest if requested - if opts.Plugins { - pluginDiags := generateClaudePlugin(dir, skillDirs, owner, repo) - for _, d := range pluginDiags { - switch d.severity { - case "error": - fmt.Fprintf(opts.IO.ErrOut, "%s %s\n", cs.FailureIcon(), d.message) - default: - fmt.Fprintf(opts.IO.Out, "%s %s\n", cs.SuccessIcon(), d.message) - } - } - } - if errors > 0 { return fmt.Errorf("validation failed with %d error(s)", errors) } @@ -577,9 +555,9 @@ func runPublishRelease(opts *publishOptions, client *api.Client, host, owner, re // 4. Inform if not on default branch var currentBranch string if opts.GitClient != nil { - bc := *opts.GitClient - bc.RepoDir = dir - if b, err := bc.CurrentBranch(context.Background()); err == nil { + branchGitClient := opts.GitClient.Copy() + branchGitClient.RepoDir = dir + if b, err := branchGitClient.CurrentBranch(context.Background()); err == nil { currentBranch = b } } @@ -597,7 +575,7 @@ func runPublishRelease(opts *publishOptions, client *api.Client, host, owner, re } if !confirmed { fmt.Fprintf(opts.IO.ErrOut, "Publish cancelled.\n") - return nil + return cmdutil.CancelError } } @@ -825,9 +803,9 @@ func checkInstalledSkillDirs(gitClient *git.Client, repoDir string) []publishDia } if gitClient != nil { - ic := *gitClient - ic.RepoDir = repoDir - if ic.IsIgnored(context.Background(), relPath) { + ignoreGitClient := gitClient.Copy() + ignoreGitClient.RepoDir = repoDir + if ignoreGitClient.IsIgnored(context.Background(), relPath) { continue } } @@ -899,7 +877,7 @@ func detectGitHubRemote(gitClient *git.Client) (owner, repo string) { // parseGitHubURL extracts owner/repo from a GitHub remote URL. // Only GitHub.com URLs are recognized. func parseGitHubURL(rawURL string) (owner, repo string) { - u, err := giturl.ParseURL(rawURL) + u, err := git.ParseURL(rawURL) if err != nil { return "", "" } @@ -921,16 +899,16 @@ func detectMissingRepoDiagnostic(gitClient *git.Client, dir string) []publishDia return nil } - dc := *gitClient - dc.RepoDir = dir - if _, err := dc.GitDir(context.Background()); err != nil { + dirGitClient := gitClient.Copy() + dirGitClient.RepoDir = dir + if _, err := dirGitClient.GitDir(context.Background()); err != nil { return []publishDiagnostic{{ severity: "warning", message: "not a git repository — initialize with: git init && gh repo create", }} } - remotes, err := dc.Remotes(context.Background()) + remotes, err := dirGitClient.Remotes(context.Background()) if err != nil || len(remotes) == 0 { return []publishDiagnostic{{ severity: "warning", @@ -940,7 +918,7 @@ func detectMissingRepoDiagnostic(gitClient *git.Client, dir string) []publishDia var urls []string for _, r := range remotes { - if url, err := dc.RemoteURL(context.Background(), r.Name); err == nil { + if url, err := dirGitClient.RemoteURL(context.Background(), r.Name); err == nil { urls = append(urls, url) } } @@ -1062,185 +1040,3 @@ func stripGitHubMetadata(content string) (string, error) { return frontmatter.Serialize(result.RawYAML, result.Body) } - -// claudePluginJSON is the .claude-plugin/plugin.json structure. -type claudePluginJSON struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - Version string `json:"version,omitempty"` - Author *claudeAuthor `json:"author,omitempty"` - Homepage string `json:"homepage,omitempty"` - Repository string `json:"repository,omitempty"` - License string `json:"license,omitempty"` - Keywords []string `json:"keywords,omitempty"` -} - -type claudeAuthor struct { - Name string `json:"name"` -} - -// claudeMarketplaceJSON is the .claude-plugin/marketplace.json structure. -type claudeMarketplaceJSON struct { - Name string `json:"name"` - Owner claudeAuthor `json:"owner"` - Plugins []claudeMarketplacePlugin `json:"plugins"` -} - -type claudeMarketplacePlugin struct { - Name string `json:"name"` - Source string `json:"source"` - Description string `json:"description,omitempty"` -} - -// generateClaudePlugin creates .claude-plugin/plugin.json (and optionally -// marketplace.json for multi-skill repos). -func generateClaudePlugin(dir string, skillDirs []string, owner, repo string) []publishDiagnostic { - var diags []publishDiagnostic - - pluginDir := filepath.Join(dir, ".claude-plugin") - pluginPath := filepath.Join(pluginDir, "plugin.json") - - // Don't overwrite existing plugin.json - if _, err := os.Stat(pluginPath); err == nil { - diags = append(diags, publishDiagnostic{ - severity: "info", - message: ".claude-plugin/plugin.json already exists (skipped)", - }) - return diags - } - - pluginName := filepath.Base(dir) - if repo != "" { - pluginName = repo - } - - description := buildPluginDescription(dir, skillDirs) - - plugin := claudePluginJSON{ - Name: pluginName, - Description: description, - Version: "1.0.0", - Keywords: []string{"agent-skills"}, - } - - if owner != "" && repo != "" { - plugin.Repository = fmt.Sprintf("https://github.com/%s/%s", owner, repo) - plugin.Homepage = fmt.Sprintf("https://github.com/%s/%s", owner, repo) - plugin.Author = &claudeAuthor{Name: owner} - } - - // Collect license from any skill - for _, skillName := range skillDirs { - skillPath := filepath.Join(dir, "skills", skillName, "SKILL.md") - content, err := os.ReadFile(skillPath) - if err != nil { - continue - } - result, err := frontmatter.Parse(string(content)) - if err != nil { - continue - } - if result.Metadata.License != "" { - plugin.License = result.Metadata.License - break - } - } - - if err := os.MkdirAll(pluginDir, 0o755); err != nil { - diags = append(diags, publishDiagnostic{ - severity: "error", - message: fmt.Sprintf("could not create .claude-plugin/: %v", err), - }) - return diags - } - - data, err := json.MarshalIndent(plugin, "", " ") - if err != nil { - diags = append(diags, publishDiagnostic{ - severity: "error", - message: fmt.Sprintf("could not serialize plugin.json: %v", err), - }) - return diags - } - - if err := os.WriteFile(pluginPath, append(data, '\n'), 0o644); err != nil { - diags = append(diags, publishDiagnostic{ - severity: "error", - message: fmt.Sprintf("could not write plugin.json: %v", err), - }) - return diags - } - - diags = append(diags, publishDiagnostic{ - severity: "info", - message: fmt.Sprintf("generated .claude-plugin/plugin.json for %q with %d skill(s)", pluginName, len(skillDirs)), - }) - - // Generate marketplace.json for multi-skill repos with a GitHub remote - if len(skillDirs) > 1 && owner != "" && repo != "" { - marketplacePath := filepath.Join(pluginDir, "marketplace.json") - if _, err := os.Stat(marketplacePath); err != nil { - mDiags := generateMarketplace(marketplacePath, pluginName, owner, skillDirs, dir) - diags = append(diags, mDiags...) - } - } - - return diags -} - -// generateMarketplace creates a marketplace.json for plugin marketplace discovery. -func generateMarketplace(path, pluginName, owner string, skillDirs []string, dir string) []publishDiagnostic { - desc := buildPluginDescription(dir, skillDirs) - plugins := []claudeMarketplacePlugin{{ - Name: pluginName, - Source: ".", - Description: desc, - }} - - marketplace := claudeMarketplaceJSON{ - Name: pluginName, - Owner: claudeAuthor{Name: owner}, - Plugins: plugins, - } - - data, err := json.MarshalIndent(marketplace, "", " ") - if err != nil { - return []publishDiagnostic{{ - severity: "error", - message: fmt.Sprintf("could not serialize marketplace.json: %v", err), - }} - } - - if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { - return []publishDiagnostic{{ - severity: "error", - message: fmt.Sprintf("could not write marketplace.json: %v", err), - }} - } - - return []publishDiagnostic{{ - severity: "info", - message: "generated .claude-plugin/marketplace.json for plugin marketplace discovery", - }} -} - -// buildPluginDescription creates a description from skill names and descriptions. -func buildPluginDescription(dir string, skillDirs []string) string { - if len(skillDirs) == 1 { - skillPath := filepath.Join(dir, "skills", skillDirs[0], "SKILL.md") - if content, err := os.ReadFile(skillPath); err == nil { - if result, err := frontmatter.Parse(string(content)); err == nil && result.Metadata.Description != "" { - return result.Metadata.Description - } - } - } - - var names []string - for _, name := range skillDirs { - names = append(names, name) - } - if len(names) <= 5 { - return fmt.Sprintf("Agent skills: %s", strings.Join(names, ", ")) - } - return fmt.Sprintf("Agent skills collection with %d skills", len(names)) -} diff --git a/pkg/cmd/skills/publish/publish_test.go b/pkg/cmd/skills/publish/publish_test.go index 56e3b1e0a..e4c368b70 100644 --- a/pkg/cmd/skills/publish/publish_test.go +++ b/pkg/cmd/skills/publish/publish_test.go @@ -2,24 +2,25 @@ package publish import ( "bytes" - "encoding/json" - "fmt" "net/http" "os" "os/exec" "path/filepath" - "strings" "testing" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func testPublishGitClient(t *testing.T, remoteURLs map[string]string) *git.Client { +func newTestGitClient(t *testing.T, remoteURLs map[string]string) *git.Client { t.Helper() dir := t.TempDir() runGit := func(args ...string) { @@ -38,77 +39,29 @@ func testPublishGitClient(t *testing.T, remoteURLs map[string]string) *git.Clien return &git.Client{RepoDir: dir} } -func TestPublishCmd_Help(t *testing.T) { - ios, _, _, _ := iostreams.Test() - f := stubFactory(ios) - cmd := NewCmdPublish(&f, nil) - if cmd.Use == "" { - t.Error("publish command has no Use string") - } - if cmd.Short == "" { - t.Error("publish command has no Short description") - } -} - -func TestPublishCmd_Alias(t *testing.T) { - ios, _, _, _ := iostreams.Test() - f := stubFactory(ios) - cmd := NewCmdPublish(&f, nil) - found := false - for _, alias := range cmd.Aliases { - if alias == "validate" { - found = true - break - } - } - if !found { - t.Error("publish command should have 'validate' alias") - } -} - -func TestPublish_ValidSkill(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "git-commit") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - - content := `--- -name: git-commit -description: A skill for writing good git commits -allowed-tools: git -license: MIT ---- -You are a git commit expert. -` - if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - ios, _, stdout, _ := iostreams.Test() - - reg := &httpmock.Registry{} - defer reg.Verify(t) +// stubAllSecureRemote registers the standard stubs for a fully-configured remote +// repo (topics, tags, rulesets, security) so publishRun skips all remote warnings. +func stubAllSecureRemote(reg *httpmock.Registry, owner, repo string) { reg.Register( - httpmock.REST("GET", "repos/test/skills-repo/topics"), + httpmock.REST("GET", "repos/"+owner+"/"+repo+"/topics"), httpmock.JSONResponse(map[string]interface{}{ "names": []string{"agent-skills"}, }), ) reg.Register( - httpmock.REST("GET", "repos/test/skills-repo/tags"), + httpmock.REST("GET", "repos/"+owner+"/"+repo+"/tags"), httpmock.JSONResponse([]map[string]interface{}{ {"name": "v1.0.0"}, }), ) reg.Register( - httpmock.REST("GET", "repos/test/skills-repo/rulesets"), + httpmock.REST("GET", "repos/"+owner+"/"+repo+"/rulesets"), httpmock.JSONResponse([]map[string]interface{}{ {"id": 1, "name": "tags", "target": "tag", "enforcement": "active"}, }), ) reg.Register( - httpmock.REST("GET", "repos/test/skills-repo"), + httpmock.REST("GET", "repos/"+owner+"/"+repo), httpmock.JSONResponse(map[string]interface{}{ "security_and_analysis": map[string]interface{}{ "secret_scanning": map[string]interface{}{"status": "enabled"}, @@ -116,944 +69,1267 @@ You are a git commit expert. }, }), ) - - opts := &publishOptions{ - IO: ios, - Dir: dir, - GitClient: testPublishGitClient(t, map[string]string{ - "origin": "https://github.com/test/skills-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", - } - - err := publishRun(opts) - if err != nil { - t.Fatalf("expected no error, got: %v", err) - } - - out := stdout.String() - if !strings.Contains(out, "ok") { - t.Errorf("expected 'ok' output, got: %s", out) - } } -func TestPublish_MissingName(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "git-commit") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - - content := `--- -description: A skill for writing good git commits ---- -Body text. -` - if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - ios, _, stdout, _ := iostreams.Test() - - opts := &publishOptions{ - IO: ios, - Dir: dir, - } - - err := publishRun(opts) - if err == nil { - t.Fatal("expected error for missing name") - } - - out := stdout.String() - if !strings.Contains(out, "missing required field: name") { - t.Errorf("expected name error in output, got: %s", out) - } -} - -func TestPublish_NameMismatch(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "git-commit") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - - content := `--- -name: wrong-name -description: A skill ---- -Body. -` - if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - ios, _, stdout, _ := iostreams.Test() - - opts := &publishOptions{ - IO: ios, - Dir: dir, - } - - err := publishRun(opts) - if err == nil { - t.Fatal("expected error for name mismatch") - } - - out := stdout.String() - if !strings.Contains(out, "does not match directory name") { - t.Errorf("expected name mismatch error, got: %s", out) - } -} - -func TestPublish_NonSpecCompliantName(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "My_Skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - - content := `--- -name: My_Skill -description: A skill with non-compliant name ---- -Body. -` - if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - ios, _, stdout, _ := iostreams.Test() - - opts := &publishOptions{ - IO: ios, - Dir: dir, - } - - err := publishRun(opts) - if err == nil { - t.Fatal("expected error for non-spec-compliant name") - } - - out := stdout.String() - if !strings.Contains(out, "naming convention") { - t.Errorf("expected naming convention error, got: %s", out) - } -} - -func TestPublish_AllowedToolsArray(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "bad-tools") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - - content := `--- -name: bad-tools -description: A skill with array allowed-tools -allowed-tools: - - git - - curl ---- -Body. -` - if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - ios, _, stdout, _ := iostreams.Test() - - opts := &publishOptions{ - IO: ios, - Dir: dir, - } - - err := publishRun(opts) - if err == nil { - t.Fatal("expected error for array allowed-tools") - } - - out := stdout.String() - if !strings.Contains(out, "allowed-tools must be a string") { - t.Errorf("expected allowed-tools error, got: %s", out) - } -} - -func TestPublish_StripMetadata(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "test-skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - - content := `--- -name: test-skill -description: A test skill -metadata: - github-owner: someone - github-repo: something - github-ref: v1.0.0 - github-sha: abc123 - github-tree-sha: def456 ---- -Body. -` - skillPath := filepath.Join(skillDir, "SKILL.md") - if err := os.WriteFile(skillPath, []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - ios, _, _, _ := iostreams.Test() - - opts := &publishOptions{ - IO: ios, - Dir: dir, - Fix: true, - } - - err := publishRun(opts) - if err != nil { - t.Fatalf("expected no error with --fix, got: %v", err) - } - - fixed, err := os.ReadFile(skillPath) - if err != nil { - t.Fatal(err) - } - - fixedStr := string(fixed) - if strings.Contains(fixedStr, "github-owner") { - t.Errorf("expected github-owner to be stripped, got:\n%s", fixedStr) - } - if strings.Contains(fixedStr, "github-sha") { - t.Errorf("expected github-sha to be stripped, got:\n%s", fixedStr) - } - if strings.Contains(fixedStr, "metadata:") { - t.Errorf("expected empty metadata map to be removed, got:\n%s", fixedStr) - } -} - -func TestPublish_MetadataWithoutFix(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "test-skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - - content := `--- -name: test-skill -description: A test skill -metadata: - github-owner: someone - github-sha: abc123 ---- -Body. -` - if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - ios, _, stdout, _ := iostreams.Test() - - opts := &publishOptions{ - IO: ios, - Dir: dir, - Fix: false, - } - - err := publishRun(opts) - if err == nil { - t.Fatal("expected error without --fix when metadata present") - } - - out := stdout.String() - if !strings.Contains(out, "install metadata") { - t.Errorf("expected install metadata error, got: %s", out) - } - if !strings.Contains(out, "--fix") { - t.Errorf("expected --fix suggestion, got: %s", out) - } -} - -func TestPublish_NoSkillsDir(t *testing.T) { - dir := t.TempDir() - ios, _, _, _ := iostreams.Test() - - opts := &publishOptions{ - IO: ios, - Dir: dir, - } - - err := publishRun(opts) - if err == nil { - t.Fatal("expected error for missing skills/ directory") - } - if !strings.Contains(err.Error(), "no skills/ directory") { - t.Errorf("expected 'no skills/ directory' error, got: %v", err) - } -} - -func TestPublish_MissingSKILLMD(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "empty-skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - - ios, _, stdout, _ := iostreams.Test() - - opts := &publishOptions{ - IO: ios, - Dir: dir, - } - - err := publishRun(opts) - if err == nil { - t.Fatal("expected error for missing SKILL.md") - } - - out := stdout.String() - if !strings.Contains(out, "missing SKILL.md") { - t.Errorf("expected missing SKILL.md error, got: %s", out) - } -} - -func TestPublish_DryRun(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "good-skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - - content := `--- -name: good-skill -description: A good skill -license: MIT ---- -Body. -` - if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - ios, _, _, stderr := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStderrTTY(true) - - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.REST("GET", "repos/test/skills-repo/topics"), - httpmock.JSONResponse(map[string]interface{}{ - "names": []string{"agent-skills"}, - }), - ) - reg.Register( - httpmock.REST("GET", "repos/test/skills-repo/tags"), - httpmock.JSONResponse([]map[string]interface{}{ - {"name": "v1.0.0"}, - }), - ) - reg.Register( - httpmock.REST("GET", "repos/test/skills-repo/rulesets"), - httpmock.JSONResponse([]map[string]interface{}{ - {"id": 1, "name": "tags", "target": "tag", "enforcement": "active"}, - }), - ) - reg.Register( - httpmock.REST("GET", "repos/test/skills-repo"), - httpmock.JSONResponse(map[string]interface{}{ - "security_and_analysis": map[string]interface{}{ - "secret_scanning": map[string]interface{}{"status": "enabled"}, - "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, - }, - }), - ) - - opts := &publishOptions{ - IO: ios, - Dir: dir, - DryRun: true, - GitClient: testPublishGitClient(t, map[string]string{ - "origin": "https://github.com/test/skills-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", - } - - err := publishRun(opts) - if err != nil { - t.Fatalf("expected no error, got: %v", err) - } - - errOut := stderr.String() - if !strings.Contains(errOut, "Dry run complete") { - t.Errorf("stderr should confirm dry run, got: %s", errOut) - } -} - -func TestPublish_LicenseWarning(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "no-license") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - - content := `--- -name: no-license -description: A skill without license ---- -Body. -` - if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - ios, _, stdout, _ := iostreams.Test() - - opts := &publishOptions{ - IO: ios, - Dir: dir, - } - - err := publishRun(opts) - if err != nil { - t.Fatalf("expected no error (warnings only), got: %v", err) - } - - out := stdout.String() - if !strings.Contains(out, "license") { - t.Errorf("expected license warning, got: %s", out) - } -} - -func TestSuggestNextTag(t *testing.T) { +func TestNewCmdPublish(t *testing.T) { tests := []struct { - input string - want string + name string + cli string + wantsErr bool + wantsOpts publishOptions }{ - {"v1.0.0", "v1.0.1"}, - {"v2.3.4", "v2.3.5"}, - {"1.0.0", "1.0.1"}, - {"v0.0.9", "v0.0.10"}, - {"not-semver", ""}, - {"v1", ""}, - {"v1.0", ""}, + { + name: "all flags", + cli: "./monalisa-skills --dry-run --fix --tag v1.0.0", + wantsOpts: publishOptions{ + Dir: "./monalisa-skills", + DryRun: true, + Fix: true, + Tag: "v1.0.0", + }, + }, + { + name: "directory only", + cli: "./octocat-repo", + wantsOpts: publishOptions{ + Dir: "./octocat-repo", + }, + }, + { + name: "no args leaves dir empty", + cli: "", + wantsOpts: publishOptions{}, + }, + { + name: "dry-run flag only", + cli: "--dry-run", + wantsOpts: publishOptions{ + DryRun: true, + }, + }, } for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - got := suggestNextTag(tt.input) - if got != tt.want { - t.Errorf("suggestNextTag(%q) = %q, want %q", tt.input, got, tt.want) + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := cmdutil.Factory{IOStreams: ios} + + var gotOpts *publishOptions + cmd := NewCmdPublish(&f, func(opts *publishOptions) error { + gotOpts = opts + return nil + }) + + args, err := shlex.Split(tt.cli) + require.NoError(t, err) + cmd.SetArgs(args) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err = cmd.Execute() + if tt.wantsErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, gotOpts) + assert.Equal(t, tt.wantsOpts.Dir, gotOpts.Dir) + assert.Equal(t, tt.wantsOpts.DryRun, gotOpts.DryRun) + assert.Equal(t, tt.wantsOpts.Fix, gotOpts.Fix) + assert.Equal(t, tt.wantsOpts.Tag, gotOpts.Tag) + }) + } +} + +func TestPublishRun(t *testing.T) { + tests := []struct { + name string + isTTY bool + setup func(t *testing.T, dir string) + stubs func(*httpmock.Registry) + opts func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions + verify func(t *testing.T, dir string) + wantErr string + wantStdout string + wantStderr string + }{ + { + name: "no skills directory", + setup: func(_ *testing.T, _ string) {}, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{IO: ios, Dir: dir} + }, + wantErr: "no skills/ directory", + }, + { + name: "missing SKILL.md", + setup: func(t *testing.T, dir string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "skills", "empty-skill"), 0o755)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{IO: ios, Dir: dir} + }, + wantErr: "validation failed", + wantStdout: "missing SKILL.md", + }, + { + name: "missing name in frontmatter", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "git-commit", heredoc.Doc(` + --- + description: A skill for writing good git commits + --- + Body text. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{IO: ios, Dir: dir} + }, + wantErr: "validation failed", + wantStdout: "missing required field: name", + }, + { + name: "name does not match directory", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "git-commit", heredoc.Doc(` + --- + name: wrong-name + description: A skill + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{IO: ios, Dir: dir} + }, + wantErr: "validation failed", + wantStdout: "does not match directory name", + }, + { + name: "non-spec-compliant name", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "My_Skill", heredoc.Doc(` + --- + name: My_Skill + description: A skill with non-compliant name + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{IO: ios, Dir: dir} + }, + wantErr: "validation failed", + wantStdout: "naming convention", + }, + { + name: "valid skill dry-run passes validation", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "good-skill", heredoc.Doc(` + --- + name: good-skill + description: A good skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + DryRun: true, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "1 skill(s) validated successfully", + wantStderr: "Dry run complete", + }, + { + name: "valid skill with --tag publishes release", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "git-commit", heredoc.Doc(` + --- + name: git-commit + description: A skill for writing good git commits + allowed-tools: git + license: MIT + --- + You are a git commit expert. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + // topic already present, so no PUT needed + // immutable releases check + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": true}), + ) + // default branch for branch comparison + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"}), + ) + // create release + reg.Register( + httpmock.REST("POST", "repos/monalisa/skills-repo/releases"), + httpmock.JSONResponse(map[string]interface{}{ + "html_url": "https://github.com/monalisa/skills-repo/releases/tag/v1.0.1", + }), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + Tag: "v1.0.1", + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, def bool) (bool, error) { return true, nil }, + }, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "Published v1.0.1", + }, + { + name: "strip metadata with --fix", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "test-skill", heredoc.Doc(` + --- + name: test-skill + description: A test skill + metadata: + github-owner: someone + github-repo: something + github-ref: v1.0.0 + github-sha: abc123 + github-tree-sha: def456 + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{IO: ios, Dir: dir, Fix: true} + }, + wantStdout: "stripped install metadata", + verify: func(t *testing.T, dir string) { + t.Helper() + fixed, err := os.ReadFile(filepath.Join(dir, "skills", "test-skill", "SKILL.md")) + require.NoError(t, err) + fixedStr := string(fixed) + assert.NotContains(t, fixedStr, "github-owner") + assert.NotContains(t, fixedStr, "github-sha") + assert.NotContains(t, fixedStr, "metadata:") + }, + }, + { + name: "metadata without --fix errors with hint", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "test-skill", heredoc.Doc(` + --- + name: test-skill + description: A test skill + metadata: + github-owner: someone + github-sha: abc123 + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{IO: ios, Dir: dir, Fix: false} + }, + wantErr: "validation failed", + wantStdout: "--fix", + }, + { + name: "missing license warning", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "no-license", heredoc.Doc(` + --- + name: no-license + description: A skill without license + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{IO: ios, Dir: dir} + }, + wantStdout: "license", + }, + { + name: "allowed-tools array error", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "bad-tools", heredoc.Doc(` + --- + name: bad-tools + description: A skill with array allowed-tools + allowed-tools: + - git + - curl + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{IO: ios, Dir: dir} + }, + wantErr: "validation failed", + wantStdout: "allowed-tools must be a string", + }, + { + name: "security warnings when features disabled", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/octocat/secure-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"agent-skills"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/secure-repo/tags"), + httpmock.JSONResponse([]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/secure-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "branch-only", "target": "branch", "enforcement": "active"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/secure-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "disabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "disabled"}, + }, + }), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/octocat/secure-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "secret scanning is not enabled", + }, + { + name: "tag protection warning", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/octocat/tag-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"agent-skills"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/tag-repo/tags"), + httpmock.JSONResponse([]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/tag-repo/rulesets"), + httpmock.JSONResponse([]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/tag-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/octocat/tag-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "tag protection", + }, + { + name: "code files trigger code scanning info", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "code-skill", heredoc.Doc(` + --- + name: code-skill + description: A skill with code + license: MIT + --- + Body. + `)) + scriptDir := filepath.Join(dir, "skills", "code-skill", "scripts") + require.NoError(t, os.MkdirAll(scriptDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(scriptDir, "helper.sh"), []byte("#!/bin/bash"), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/octocat/code-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"agent-skills"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/code-repo/tags"), + httpmock.JSONResponse([]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/code-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "tags", "target": "tag", "enforcement": "active"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/code-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/code-repo/code-scanning/alerts"), + httpmock.StatusStringResponse(404, "not found"), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + DryRun: true, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/octocat/code-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStderr: "code scanning", + }, + { + name: "manifest files trigger dependabot info", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "dep-skill", heredoc.Doc(` + --- + name: dep-skill + description: A skill with manifests + license: MIT + --- + Body. + `)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "skills", "dep-skill", "package.json"), + []byte("{}"), 0o644, + )) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/octocat/dep-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"agent-skills"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/dep-repo/tags"), + httpmock.JSONResponse([]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/dep-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "tags", "target": "tag", "enforcement": "active"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/dep-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/dep-repo/vulnerability-alerts"), + httpmock.StatusStringResponse(404, "not found"), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + DryRun: true, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/octocat/dep-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStderr: "Dependabot", + }, + { + name: "installed skill dirs not gitignored warns", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".github", "skills", "installed"), 0o755)) + runGitInDir(t, dir, "init", "--initial-branch=main") + runGitInDir(t, dir, "config", "user.email", "monalisa@github.com") + runGitInDir(t, dir, "config", "user.name", "Monalisa Octocat") + + return &publishOptions{ + IO: ios, + Dir: dir, + GitClient: &git.Client{RepoDir: dir}, + } + }, + wantStdout: ".gitignore", + }, + { + name: "installed skill dirs gitignored no warning", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".github", "skills", "installed"), 0o755)) + + runGitInDir(t, dir, "init", "--initial-branch=main") + runGitInDir(t, dir, "config", "user.email", "monalisa@github.com") + runGitInDir(t, dir, "config", "user.name", "Monalisa Octocat") + require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(".github/skills\n"), 0o644)) + runGitInDir(t, dir, "add", ".gitignore") + runGitInDir(t, dir, "commit", "-m", "init") + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + GitClient: &git.Client{RepoDir: dir}, + } + }, + wantStdout: "no git remote", + verify: func(t *testing.T, dir string) { + t.Helper() + // The key assertion: .gitignored dirs should NOT produce a warning + }, + }, + { + name: "no GitHub remote warns", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + runGitInDir(t, dir, "init", "--initial-branch=main") + runGitInDir(t, dir, "config", "user.email", "monalisa@github.com") + runGitInDir(t, dir, "config", "user.name", "Monalisa Octocat") + runGitInDir(t, dir, "remote", "add", "origin", "https://gitlab.com/hubot/bar.git") + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + GitClient: &git.Client{RepoDir: dir}, + } + }, + wantStdout: "not a GitHub repository", + }, + { + name: "fallback remote detection uses non-origin GitHub remote", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "octocat", "repo") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + DryRun: true, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://gitlab.com/hubot/bar.git", + "upstream": "git@github.com:octocat/repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStderr: "octocat/repo", + }, + { + name: "publish adds missing topic via --tag", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + // topic missing + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"golang"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/tags"), + httpmock.JSONResponse([]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "tags", "target": "tag", "enforcement": "active"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + // addAgentSkillsTopic fetches topics again then PUTs + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"golang"}, + }), + ) + reg.Register( + httpmock.REST("PUT", "repos/monalisa/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{}), + ) + // immutable releases + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": true}), + ) + // default branch + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"}), + ) + // create release + reg.Register( + httpmock.REST("POST", "repos/monalisa/skills-repo/releases"), + httpmock.JSONResponse(map[string]interface{}{ + "html_url": "https://github.com/monalisa/skills-repo/releases/tag/v1.0.0", + }), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + Tag: "v1.0.0", + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, def bool) (bool, error) { return true, nil }, + }, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "Added \"agent-skills\" topic", + }, + { + name: "tag suggestion uses existing tags", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"agent-skills"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/tags"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "v2.3.4"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "tags", "target": "tag", "enforcement": "active"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + // immutable releases + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": true}), + ) + // default branch + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"}), + ) + // create release with the suggested v2.3.5 tag + reg.Register( + httpmock.REST("POST", "repos/monalisa/skills-repo/releases"), + httpmock.JSONResponse(map[string]interface{}{ + "html_url": "https://github.com/monalisa/skills-repo/releases/tag/v2.3.5", + }), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + Tag: "v2.3.5", + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "Published v2.3.5", + }, + { + name: "duplicate tag errors", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + Tag: "v1.0.0", // same as stubAllSecureRemote's existing tag + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantErr: "tag v1.0.0 already exists", + }, + { + name: "valid skill non-tty plain output", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "git-commit", heredoc.Doc(` + --- + name: git-commit + description: A skill for writing good git commits + allowed-tools: git + license: MIT + --- + You are a git commit expert. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "ok", + }, + { + name: "no remote and non-tty shows validation passed message", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + } + }, + wantStdout: "ok", + }, + { + name: "interactive publish with topic and semver tag", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + // No topic yet — first GET for diagnostic check + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{"names": []string{}}), + ) + // Second GET inside addAgentSkillsTopic + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{"names": []string{}}), + ) + // Add topic + reg.Register( + httpmock.REST("PUT", "repos/monalisa/skills-repo/topics"), + httpmock.StringResponse("{}"), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/tags"), + httpmock.JSONResponse([]map[string]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "default_branch": "main", + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + // Immutable releases already enabled + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": true}), + ) + // Create release + reg.Register( + httpmock.REST("POST", "repos/monalisa/skills-repo/releases"), + httpmock.JSONResponse(map[string]interface{}{ + "html_url": "https://github.com/monalisa/skills-repo/releases/tag/v1.0.0", + }), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + confirmCall := 0 + return &publishOptions{ + IO: ios, + Dir: dir, + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, def bool) (bool, error) { + confirmCall++ + return true, nil // accept topic + final confirm + }, + SelectFunc: func(msg string, def string, opts []string) (int, error) { + return 0, nil // semver strategy + }, + InputFunc: func(msg string, def string) (string, error) { + return "v1.0.0", nil // accept suggested tag + }, + }, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "Published v1.0.0", + }, + { + name: "interactive publish with custom tag", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": true}), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"}), + ) + reg.Register( + httpmock.REST("POST", "repos/monalisa/skills-repo/releases"), + httpmock.JSONResponse(map[string]interface{}{ + "html_url": "https://github.com/monalisa/skills-repo/releases/tag/beta-1", + }), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, def bool) (bool, error) { + return true, nil + }, + SelectFunc: func(msg string, def string, opts []string) (int, error) { + return 1, nil // custom tag strategy + }, + InputFunc: func(msg string, def string) (string, error) { + return "beta-1", nil + }, + }, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "Published beta-1", + }, + { + name: "interactive publish declined at final confirm", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": true}), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"}), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + confirmCall := 0 + return &publishOptions{ + IO: ios, + Dir: dir, + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, def bool) (bool, error) { + confirmCall++ + if confirmCall >= 1 { + return false, nil // decline final confirm + } + return true, nil + }, + SelectFunc: func(msg string, def string, opts []string) (int, error) { + return 0, nil + }, + InputFunc: func(msg string, def string) (string, error) { + return "v1.0.1", nil + }, + }, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantErr: "CancelError", + wantStderr: "Publish cancelled", + }, + { + name: "interactive immutable releases prompt", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + // Immutable releases NOT enabled + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": false}), + ) + // Enable immutable releases + reg.Register( + httpmock.REST("PATCH", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.StringResponse("{}"), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"}), + ) + reg.Register( + httpmock.REST("POST", "repos/monalisa/skills-repo/releases"), + httpmock.JSONResponse(map[string]interface{}{ + "html_url": "https://github.com/monalisa/skills-repo/releases/tag/v1.0.1", + }), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, def bool) (bool, error) { + return true, nil // accept all confirms (immutable + final) + }, + SelectFunc: func(msg string, def string, opts []string) (int, error) { + return 0, nil + }, + InputFunc: func(msg string, def string) (string, error) { + return "v1.0.1", nil + }, + }, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "Enabled immutable releases", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + tt.setup(t, dir) + + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(tt.isTTY) + ios.SetStdinTTY(tt.isTTY) + ios.SetStderrTTY(tt.isTTY) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + if tt.stubs != nil { + tt.stubs(reg) + } + + opts := tt.opts(ios, dir, reg) + err := publishRun(opts) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + } + if tt.wantStdout != "" { + assert.Contains(t, stdout.String(), tt.wantStdout) + } + if tt.wantStderr != "" { + assert.Contains(t, stderr.String(), tt.wantStderr) + } + if tt.verify != nil { + tt.verify(t, dir) } }) } } -func TestParseGitHubURL(t *testing.T) { - tests := []struct { - url string - wantOwner string - wantRepo string - }{ - {"git@github.com:github/gh-skills.git", "github", "gh-skills"}, - {"https://github.com/github/gh-skills.git", "github", "gh-skills"}, - {"https://github.com/github/gh-skills", "github", "gh-skills"}, - {"git@github.com:owner/repo.git", "owner", "repo"}, - {"https://gitlab.com/owner/repo.git", "", ""}, - {"not-a-url", "", ""}, - } - for _, tt := range tests { - t.Run(tt.url, func(t *testing.T) { - owner, repo := parseGitHubURL(tt.url) - if owner != tt.wantOwner || repo != tt.wantRepo { - t.Errorf("parseGitHubURL(%q) = (%q, %q), want (%q, %q)", tt.url, owner, repo, tt.wantOwner, tt.wantRepo) - } - }) - } +// writeSkill creates skills//SKILL.md with the given content. +func writeSkill(t *testing.T, dir, name, content string) { + t.Helper() + skillDir := filepath.Join(dir, "skills", name) + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) } -func TestRepoHasTopic(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/topics"), - httpmock.JSONResponse(map[string]interface{}{ - "names": []string{"golang", "agent-skills"}, - }), - ) - - if !repoHasTopic(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo") { - t.Error("expected true when topic present") - } -} - -func TestRepoHasTopic_Missing(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/topics"), - httpmock.JSONResponse(map[string]interface{}{ - "names": []string{"golang"}, - }), - ) - - if repoHasTopic(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo") { - t.Error("expected false when topic missing") - } -} - -func TestFetchTags_NoTags(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/tags"), - httpmock.JSONResponse([]interface{}{}), - ) - - tags := fetchTags(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo") - if len(tags) != 0 { - t.Errorf("expected no tags, got %d", len(tags)) - } -} - -func TestFetchTags_WithTags(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/tags"), - httpmock.JSONResponse([]map[string]interface{}{ - {"name": "v1.2.3"}, - }), - ) - - tags := fetchTags(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo") - if len(tags) != 1 { - t.Fatalf("expected 1 tag, got %d", len(tags)) - } - if tags[0].Name != "v1.2.3" { - t.Errorf("expected v1.2.3, got %s", tags[0].Name) - } -} - -func TestCheckTagProtection_Active(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/rulesets"), - httpmock.JSONResponse([]map[string]interface{}{ - {"id": 1, "name": "protect-tags", "target": "tag", "enforcement": "active"}, - }), - ) - - diags := checkTagProtection(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo") - if len(diags) != 0 { - t.Errorf("expected no diagnostics when tag protection active, got: %v", diags) - } -} - -func TestCheckTagProtection_Missing(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/rulesets"), - httpmock.JSONResponse([]map[string]interface{}{ - {"id": 1, "name": "branch-protection", "target": "branch", "enforcement": "active"}, - }), - ) - - diags := checkTagProtection(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo") - if len(diags) != 1 { - t.Fatalf("expected 1 diagnostic, got %d", len(diags)) - } - if !strings.Contains(diags[0].message, "tag protection") { - t.Errorf("expected tag protection warning, got: %s", diags[0].message) - } -} - -func TestCheckSecuritySettings_AllEnabled(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.REST("GET", "repos/owner/repo"), - httpmock.JSONResponse(map[string]interface{}{ - "security_and_analysis": map[string]interface{}{ - "secret_scanning": map[string]interface{}{"status": "enabled"}, - "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, - }, - }), - ) - - skillsDir := t.TempDir() - - diags := checkSecuritySettings(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo", skillsDir) - if len(diags) != 0 { - t.Errorf("expected no diagnostics when all security enabled, got %d: %v", len(diags), diags) - } -} - -func TestCheckSecuritySettings_NoneEnabled(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.REST("GET", "repos/owner/repo"), - httpmock.JSONResponse(map[string]interface{}{ - "security_and_analysis": map[string]interface{}{ - "secret_scanning": map[string]interface{}{"status": "disabled"}, - "secret_scanning_push_protection": map[string]interface{}{"status": "disabled"}, - }, - }), - ) - - skillsDir := t.TempDir() - - diags := checkSecuritySettings(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo", skillsDir) - if len(diags) != 2 { - t.Errorf("expected 2 diagnostics (secret scanning + push protection), got %d: %v", len(diags), diags) - } - for _, d := range diags { - if d.severity != "warning" { - t.Errorf("secret scanning diagnostics should be warnings, got %q: %s", d.severity, d.message) - } - } -} - -func TestCheckSecuritySettings_WithCodeFiles(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.REST("GET", "repos/owner/repo"), - httpmock.JSONResponse(map[string]interface{}{ - "security_and_analysis": map[string]interface{}{ - "secret_scanning": map[string]interface{}{"status": "enabled"}, - "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, - }, - }), - ) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/code-scanning/alerts"), - httpmock.StatusStringResponse(404, "not found"), - ) - - skillsDir := t.TempDir() - scriptDir := filepath.Join(skillsDir, "my-skill", "scripts") - if err := os.MkdirAll(scriptDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(scriptDir, "helper.sh"), []byte("#!/bin/bash"), 0o644); err != nil { - t.Fatal(err) - } - - diags := checkSecuritySettings(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo", skillsDir) - hasCodeScanInfo := false - for _, d := range diags { - if strings.Contains(d.message, "code scanning") { - hasCodeScanInfo = true - if d.severity != "info" { - t.Errorf("code scanning suggestion should be info, got %q", d.severity) - } - } - } - if !hasCodeScanInfo { - t.Error("expected code scanning info when code files present") - } -} - -func TestCheckSecuritySettings_WithManifests(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.REST("GET", "repos/owner/repo"), - httpmock.JSONResponse(map[string]interface{}{ - "security_and_analysis": map[string]interface{}{ - "secret_scanning": map[string]interface{}{"status": "enabled"}, - "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, - }, - }), - ) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/vulnerability-alerts"), - httpmock.StatusStringResponse(404, "not found"), - ) - - skillsDir := t.TempDir() - skillDir := filepath.Join(skillsDir, "my-skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(skillDir, "package.json"), []byte("{}"), 0o644); err != nil { - t.Fatal(err) - } - - diags := checkSecuritySettings(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo", skillsDir) - hasDependabotInfo := false - for _, d := range diags { - if strings.Contains(d.message, "Dependabot") { - hasDependabotInfo = true - if d.severity != "info" { - t.Errorf("Dependabot suggestion should be info, got %q", d.severity) - } - } - } - if !hasDependabotInfo { - t.Error("expected Dependabot info when manifest files present") - } -} - -func TestDetectCodeAndManifests(t *testing.T) { - dir := t.TempDir() - - hasCode, hasManifests := detectCodeAndManifests(dir) - if hasCode || hasManifests { - t.Error("empty dir should have no code or manifests") - } - - if err := os.WriteFile(filepath.Join(dir, "run.sh"), []byte("#!/bin/bash"), 0o644); err != nil { - t.Fatal(err) - } - hasCode, hasManifests = detectCodeAndManifests(dir) - if !hasCode { - t.Error("should detect .sh as code") - } - if hasManifests { - t.Error("should not detect manifests") - } - - if err := os.WriteFile(filepath.Join(dir, "requirements.txt"), []byte("flask"), 0o644); err != nil { - t.Fatal(err) - } - hasCode, hasManifests = detectCodeAndManifests(dir) - if !hasCode || !hasManifests { - t.Error("should detect both code and manifests") - } -} - -func TestCheckInstalledSkillDirs_NotPresent(t *testing.T) { - dir := t.TempDir() - diags := checkInstalledSkillDirs(nil, dir) - if len(diags) != 0 { - t.Errorf("expected no diagnostics for empty dir, got %d", len(diags)) - } -} - -func TestCheckInstalledSkillDirs_PresentNotIgnored(t *testing.T) { - gitClient := testPublishGitClient(t, nil) - dir := gitClient.RepoDir - - installedDir := filepath.Join(dir, ".github", "skills", "some-skill") - if err := os.MkdirAll(installedDir, 0o755); err != nil { - t.Fatal(err) - } - - diags := checkInstalledSkillDirs(gitClient, dir) - if len(diags) == 0 { - t.Fatal("expected warning for unignored .github/skills/") - } - if diags[0].severity != "warning" { - t.Errorf("expected warning, got %q", diags[0].severity) - } - if !strings.Contains(diags[0].message, ".gitignore") { - t.Errorf("expected .gitignore mention, got: %s", diags[0].message) - } -} - -func TestCheckInstalledSkillDirs_PresentAndIgnored(t *testing.T) { - gitClient := testPublishGitClient(t, nil) - dir := gitClient.RepoDir - - installedDir := filepath.Join(dir, ".github", "skills", "some-skill") - if err := os.MkdirAll(installedDir, 0o755); err != nil { - t.Fatal(err) - } - - // Add .gitignore so git check-ignore recognises the path. - if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(".github/skills\n"), 0o644); err != nil { - t.Fatal(err) - } - runGit := func(args ...string) { - t.Helper() - cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) - cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+dir) - out, err := cmd.CombinedOutput() - require.NoError(t, err, "git %v: %s", args, out) - } - runGit("add", ".gitignore") - runGit("commit", "-m", "init") - - diags := checkInstalledSkillDirs(gitClient, dir) - if len(diags) != 0 { - t.Errorf("expected no diagnostics when gitignored, got %d: %v", len(diags), diags) - } -} - -func TestGenerateClaudePlugin(t *testing.T) { - dir := t.TempDir() - - for _, name := range []string{"git-commit", "code-review"} { - skillDir := filepath.Join(dir, "skills", name) - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - content := fmt.Sprintf("---\nname: %s\ndescription: A %s skill\nlicense: MIT\n---\nBody.\n", name, name) - if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { - t.Fatal(err) - } - } - - diags := generateClaudePlugin(dir, []string{"git-commit", "code-review"}, "testowner", "testrepo") - - var generated int - for _, d := range diags { - if d.severity == "error" { - t.Errorf("unexpected error: %s", d.message) - } - if d.severity == "info" && strings.Contains(d.message, "generated") { - generated++ - } - } - if generated != 2 { - t.Errorf("expected 2 generated files, got %d", generated) - } - - pluginData, err := os.ReadFile(filepath.Join(dir, ".claude-plugin", "plugin.json")) - if err != nil { - t.Fatalf("plugin.json not created: %v", err) - } - var plugin claudePluginJSON - if err := json.Unmarshal(pluginData, &plugin); err != nil { - t.Fatalf("invalid plugin.json: %v", err) - } - if plugin.Name != "testrepo" { - t.Errorf("plugin.Name = %q, want %q", plugin.Name, "testrepo") - } - if plugin.License != "MIT" { - t.Errorf("plugin.License = %q, want %q", plugin.License, "MIT") - } - if plugin.Repository != "https://github.com/testowner/testrepo" { - t.Errorf("plugin.Repository = %q", plugin.Repository) - } - - marketData, err := os.ReadFile(filepath.Join(dir, ".claude-plugin", "marketplace.json")) - if err != nil { - t.Fatalf("marketplace.json not created: %v", err) - } - var marketplace claudeMarketplaceJSON - if err := json.Unmarshal(marketData, &marketplace); err != nil { - t.Fatalf("invalid marketplace.json: %v", err) - } - if marketplace.Name != "testrepo" { - t.Errorf("marketplace.Name = %q, want %q", marketplace.Name, "testrepo") - } - if len(marketplace.Plugins) != 1 || marketplace.Plugins[0].Source != "." { - t.Errorf("marketplace.Plugins = %+v", marketplace.Plugins) - } -} - -func TestGenerateClaudePlugin_SkipsExisting(t *testing.T) { - dir := t.TempDir() - - skillDir := filepath.Join(dir, "skills", "my-skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: my-skill\ndescription: test\n---\nBody.\n"), 0o644); err != nil { - t.Fatal(err) - } - - pluginDir := filepath.Join(dir, ".claude-plugin") - if err := os.MkdirAll(pluginDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(`{"name":"existing"}`), 0o644); err != nil { - t.Fatal(err) - } - - diags := generateClaudePlugin(dir, []string{"my-skill"}, "owner", "repo") - - for _, d := range diags { - if d.severity == "error" { - t.Errorf("unexpected error: %s", d.message) - } - if strings.Contains(d.message, "generated") { - t.Error("should not regenerate existing plugin.json") - } - } -} - -func TestDetectGitHubRemote(t *testing.T) { - gitClient := testPublishGitClient(t, map[string]string{ - "origin": "https://github.com/myorg/myrepo.git", - }) - - owner, repo := detectGitHubRemote(gitClient) - if owner != "myorg" || repo != "myrepo" { - t.Errorf("expected myorg/myrepo, got %s/%s", owner, repo) - } -} - -func TestDetectGitHubRemote_Fallback(t *testing.T) { - gitClient := testPublishGitClient(t, map[string]string{ - "origin": "https://gitlab.com/foo/bar.git", - "upstream": "git@github.com:org/repo.git", - }) - - owner, repo := detectGitHubRemote(gitClient) - if owner != "org" || repo != "repo" { - t.Errorf("expected org/repo, got %s/%s", owner, repo) - } -} - -func TestDetectGitHubRemote_NoGitHub(t *testing.T) { - gitClient := testPublishGitClient(t, map[string]string{ - "origin": "https://gitlab.com/foo/bar.git", - }) - - owner, repo := detectGitHubRemote(gitClient) - if owner != "" || repo != "" { - t.Errorf("expected empty, got %s/%s", owner, repo) - } -} - -func TestPublishCmd_RunFHook(t *testing.T) { - ios, _, _, _ := iostreams.Test() - f := stubFactory(ios) - - var capturedOpts *publishOptions - cmd := NewCmdPublish(&f, func(opts *publishOptions) error { - capturedOpts = opts - return nil - }) - - cmd.SetArgs([]string{"./my-skills", "--dry-run", "--fix", "--tag", "v1.0.0"}) - cmd.SetOut(&bytes.Buffer{}) - cmd.SetErr(&bytes.Buffer{}) - - err := cmd.Execute() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if capturedOpts == nil { - t.Fatal("runF was not called") - } - if capturedOpts.Dir != "./my-skills" { - t.Errorf("Dir = %q, want %q", capturedOpts.Dir, "./my-skills") - } - if !capturedOpts.DryRun { - t.Error("expected DryRun to be true") - } - if !capturedOpts.Fix { - t.Error("expected Fix to be true") - } - if capturedOpts.Tag != "v1.0.0" { - t.Errorf("Tag = %q, want %q", capturedOpts.Tag, "v1.0.0") - } -} - -// stubFactory creates a minimal cmdutil.Factory for tests. -func stubFactory(ios *iostreams.IOStreams) cmdutil.Factory { - return cmdutil.Factory{ - IOStreams: ios, - } +// runGitInDir runs a git command in the given directory with isolation env vars. +func runGitInDir(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+dir) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, out) } diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go index 0d7e39043..6a0cc95d0 100644 --- a/pkg/cmd/skills/search/search.go +++ b/pkg/cmd/skills/search/search.go @@ -365,18 +365,32 @@ func truncateForProcessing(skills []skillResult, page, limit int) []skillResult } // enrichSkills fetches descriptions and star counts concurrently. +// Each function collects results into a map; merges happen after both complete +// to avoid concurrent writes to the shared skills slice. func enrichSkills(client *api.Client, host string, skills []skillResult) { + var descMap map[int]string + var starsMap map[int]int + var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() - fetchDescriptions(client, host, skills) + descMap = fetchDescriptions(client, host, skills) }() go func() { defer wg.Done() - fetchRepoStars(client, host, skills) + starsMap = fetchRepoStars(client, host, skills) }() wg.Wait() + + for i := range skills { + if desc, ok := descMap[i]; ok { + skills[i].Description = desc + } + if stars, ok := starsMap[i]; ok { + skills[i].Stars = stars + } + } } // paginate slices results to the requested page window. @@ -423,7 +437,7 @@ func renderResults(opts *searchOptions, skills []skillResult, totalPages int) er cs := opts.IO.ColorScheme() header := fmt.Sprintf("\n%s Showing %s matching %q", cs.SuccessIcon(), - pluralize(len(skills), "skill"), + text.Pluralize(len(skills), "skill"), opts.Query, ) if totalPages > 1 { @@ -498,14 +512,14 @@ func promptInstall(opts *searchOptions, skills []skillResult) error { for i, s := range skills { starStr := "" if s.Stars > 0 { - starStr = " " + cs.Gray("★ "+formatStars(s.Stars)) + starStr = " " + cs.Muted("★ "+formatStars(s.Stars)) } descStr := "" if s.Description != "" { - desc := collapseWhitespace(s.Description) - descStr = "\n " + cs.Gray(text.Truncate(descWidth, desc)) + desc := strings.Join(strings.Fields(s.Description), " ") + descStr = "\n " + cs.Muted(text.Truncate(descWidth, desc)) } - options[i] = s.SkillName + " " + cs.Gray(s.Repo) + starStr + descStr + options[i] = s.SkillName + " " + cs.Muted(s.Repo) + starStr + descStr } indices, err := opts.Prompter.MultiSelect( @@ -564,7 +578,7 @@ func promptInstall(opts *searchOptions, skills []skillResult) error { // - Exact skill name match (10 000 points) // - Partial skill name match (1 000 points) // - Description contains query (100 points) -// - Repository stars (logarithmic bonus, up to ~700 points) +// - Repository stars (sqrt bonus, ~2 400 for 6k stars) func relevanceScore(s skillResult, query string) int { term := strings.ToLower(query) termHyphen := strings.ReplaceAll(term, " ", "-") @@ -574,7 +588,7 @@ func relevanceScore(s skillResult, query string) int { // use hyphens as word separators (e.g. query "mcp apps" → "mcp-apps"). skillLower := strings.ToLower(s.SkillName) if skillLower == term || skillLower == termHyphen { - score += 10_000 + score += 3_000 } else if strings.Contains(skillLower, term) || strings.Contains(skillLower, termHyphen) { score += 1_000 } @@ -584,10 +598,10 @@ func relevanceScore(s skillResult, query string) int { score += 100 } - // Stars bonus: use log₁₀ scaling so popular repos rank higher without - // completely drowning out less-popular but more relevant results. + // Stars bonus: use √n scaling so popular repos rank meaningfully higher + // without completely drowning out less-popular but more relevant results. if s.Stars > 0 { - score += int(math.Log10(float64(s.Stars)) * 150) + score += int(math.Sqrt(float64(s.Stars)) * 30) } return score @@ -763,12 +777,14 @@ func splitRepo(fullName string) (string, string) { // fetchDescriptions fetches SKILL.md frontmatter descriptions concurrently // for all search results. Each result may come from a different repo. -func fetchDescriptions(client *api.Client, host string, skills []skillResult) { +func fetchDescriptions(client *api.Client, host string, skills []skillResult) map[int]string { const maxWorkers = 10 sem := make(chan struct{}, maxWorkers) var wg sync.WaitGroup var mu sync.Mutex + descs := make(map[int]string) + for i := range skills { if skills[i].BlobSHA == "" { continue @@ -789,11 +805,13 @@ func fetchDescriptions(client *api.Client, host string, skills []skillResult) { } mu.Lock() - skills[idx].Description = result.Metadata.Description + descs[idx] = result.Metadata.Description mu.Unlock() }(i) } wg.Wait() + + return descs } // extractSkillName derives the skill name from a SKILL.md path, but only if @@ -803,21 +821,8 @@ func extractSkillName(filePath string) string { return discovery.MatchesSkillPath(filePath) } -func pluralize(count int, singular string) string { - if count == 1 { - return fmt.Sprintf("%d %s", count, singular) - } - return fmt.Sprintf("%d %ss", count, singular) -} - -// collapseWhitespace replaces runs of whitespace (newlines, tabs, etc.) -// with a single space. -func collapseWhitespace(s string) string { - fields := strings.Fields(s) - return strings.Join(fields, " ") -} - // formatStars formats a star count for display (e.g. 1700 → "1.7k"). +// TODO kw: Could be swaped for go-humanize. func formatStars(n int) string { if n >= 1000 { return fmt.Sprintf("%.1fk", float64(n)/1000) @@ -832,7 +837,7 @@ type repoInfo struct { // fetchRepoStars fetches stargazer counts for each unique repository in // the result set, using bounded concurrency. -func fetchRepoStars(client *api.Client, host string, skills []skillResult) { +func fetchRepoStars(client *api.Client, host string, skills []skillResult) map[int]int { const maxWorkers = 10 sem := make(chan struct{}, maxWorkers) var wg sync.WaitGroup @@ -865,9 +870,11 @@ func fetchRepoStars(client *api.Client, host string, skills []skillResult) { } wg.Wait() - for i := range skills { - if stars, ok := repoStars[skills[i].Repo]; ok { - skills[i].Stars = stars + result := make(map[int]int, len(skills)) + for i, s := range skills { + if stars, ok := repoStars[s.Repo]; ok { + result[i] = stars } } + return result } diff --git a/pkg/cmd/skills/search/search_test.go b/pkg/cmd/skills/search/search_test.go index db266f460..e3b8b26d6 100644 --- a/pkg/cmd/skills/search/search_test.go +++ b/pkg/cmd/skills/search/search_test.go @@ -1,7 +1,9 @@ package search import ( + "io" "net/http" + "strings" "testing" "github.com/cli/cli/v2/internal/config" @@ -88,19 +90,15 @@ func TestNewCmdSearch(t *testing.T) { argv := []string{} if tt.args != "" { - for _, part := range splitOnSpaces(tt.args) { - if part != "" { - argv = append(argv, part) - } - } + argv = strings.Fields(tt.args) } cmd.SetArgs(argv) - cmd.SetOut(&discardWriter{}) - cmd.SetErr(&discardWriter{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) _, err := cmd.ExecuteC() if tt.wantErr != "" { - assert.Error(t, err) + require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErr) return } @@ -252,6 +250,78 @@ func TestSearchRun(t *testing.T) { }, wantErr: rateLimitErrorMessage, }, + { + name: "HTTP 429 returns rate limit error", + tty: false, + opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + for range 3 { + reg.Register( + httpmock.REST("GET", "search/code"), + httpmock.StatusStringResponse(429, `{"message": "Too Many Requests"}`), + ) + } + }, + wantErr: rateLimitErrorMessage, + }, + { + name: "HTTP 403 with Retry-After returns rate limit error", + tty: false, + opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + for range 3 { + reg.Register( + httpmock.REST("GET", "search/code"), + httpmock.WithHeader( + httpmock.StatusJSONResponse(403, map[string]string{"message": "secondary rate limit"}), + "Retry-After", "60", + ), + ) + } + }, + wantErr: rateLimitErrorMessage, + }, + { + name: "no results with owner scope", + tty: true, + opts: &searchOptions{Query: "nonexistent", Owner: "monalisa", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + // With --owner set, only path + primary searches fire (no owner search). + for range 2 { + reg.Register( + httpmock.REST("GET", "search/code"), + httpmock.StringResponse(emptyCodeResponse), + ) + } + }, + wantErr: `no skills found matching "nonexistent" from owner "monalisa"`, + }, + { + name: "enriches results with blob descriptions", + tty: false, + opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + codeResponse := `{"total_count": 1, "incomplete_results": false, "items": [ + {"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "sha": "abc123", + "repository": {"full_name": "org/repo"}} + ]}` + stubKeywordSearch(reg, codeResponse) + // Blob fetch for description enrichment + reg.Register( + httpmock.REST("GET", "repos/org/repo/git/blobs/abc123"), + httpmock.JSONResponse(map[string]string{ + "content": "LS0tCmRlc2NyaXB0aW9uOiBBdXRvbWF0ZXMgVGVycmFmb3JtIGluZnJhc3RydWN0dXJlCi0tLQojIFRlcnJhZm9ybSBTa2lsbAo=", + "encoding": "base64", + }), + ) + // Repo stars fetch + reg.Register( + httpmock.REST("GET", "repos/org/repo"), + httpmock.JSONResponse(map[string]int{"stargazers_count": 42}), + ) + }, + wantStdout: "org/repo\tterraform\tAutomates Terraform infrastructure\t42\n", + }, } for _, tt := range tests { @@ -396,28 +466,3 @@ func TestFormatStars(t *testing.T) { assert.Equal(t, "1.7k", formatStars(1700)) assert.Equal(t, "12.5k", formatStars(12500)) } - -func splitOnSpaces(s string) []string { - var parts []string - current := "" - for _, c := range s { - if c == ' ' { - if current != "" { - parts = append(parts, current) - current = "" - } - } else { - current += string(c) - } - } - if current != "" { - parts = append(parts, current) - } - return parts -} - -type discardWriter struct{} - -func (d *discardWriter) Write(p []byte) (n int, err error) { - return len(p), nil -} diff --git a/pkg/cmd/skills/update/update.go b/pkg/cmd/skills/update/update.go index 42995a315..1dfe76007 100644 --- a/pkg/cmd/skills/update/update.go +++ b/pkg/cmd/skills/update/update.go @@ -1,7 +1,6 @@ package update import ( - "context" "fmt" "net/http" "os" @@ -38,6 +37,7 @@ type updateOptions struct { All bool // --all flag (update without prompting) Force bool // --force flag (re-download even if SHAs match) DryRun bool // --dry-run flag (report only, no changes) + Unpin bool // --unpin flag (clear pinned ref and include in update) Dir string // --dir flag (scan a custom directory) } @@ -86,7 +86,8 @@ func NewCmdUpdate(f *cmdutil.Factory, runF func(*updateOptions) error) *cobra.Co checks only those specific skills. Pinned skills (installed with --pin) are skipped with a notice. - Use "gh skills install --pin " to change the pinned version. + Use --unpin to clear the pinned version and include those skills + in the update. Skills without GitHub metadata (e.g. installed manually or by another tool) are prompted for their source repository in interactive mode. @@ -116,6 +117,9 @@ func NewCmdUpdate(f *cmdutil.Factory, runF func(*updateOptions) error) *cobra.Co # Check for updates without applying (read-only) $ gh skills update --dry-run + + # Unpin skills and update them to latest + $ gh skills update --unpin `), RunE: func(cmd *cobra.Command, args []string) error { opts.Skills = args @@ -129,6 +133,7 @@ func NewCmdUpdate(f *cmdutil.Factory, runF func(*updateOptions) error) *cobra.Co cmd.Flags().BoolVar(&opts.All, "all", false, "Update all skills without prompting") cmd.Flags().BoolVar(&opts.Force, "force", false, "Re-download even if already up to date") cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Report available updates without modifying files") + cmd.Flags().BoolVar(&opts.Unpin, "unpin", false, "Clear pinned version and include pinned skills in update") cmd.Flags().StringVar(&opts.Dir, "dir", "", "Scan a custom directory for installed skills") return cmd @@ -150,8 +155,8 @@ func updateRun(opts *updateOptions) error { } hostname, _ := cfg.Authentication().DefaultHost() - gitRoot := resolveGitRoot(opts.GitClient) - homeDir := resolveHomeDir() + gitRoot := installer.ResolveGitRoot(opts.GitClient) + homeDir := installer.ResolveHomeDir() // Scan for installed skills var installed []installedSkill @@ -162,7 +167,7 @@ func updateRun(opts *updateOptions) error { } installed = skills } else { - installed = scanAllHosts(gitRoot, homeDir) + installed = scanAllAgents(gitRoot, homeDir) } if len(installed) == 0 { @@ -238,7 +243,7 @@ func updateRun(opts *updateOptions) error { if s.owner == "" || s.repo == "" { continue } - if s.pinned != "" { + if s.pinned != "" && !opts.Unpin { pinned = append(pinned, s) continue } @@ -315,7 +320,7 @@ func updateRun(opts *updateOptions) error { } for _, s := range pinned { - fmt.Fprintf(opts.IO.ErrOut, "%s %s is pinned to %s (skipped)\n", cs.Gray("⊘"), s.name, s.pinned) + fmt.Fprintf(opts.IO.ErrOut, "%s %s is pinned to %s (skipped)\n", cs.Muted("⊘"), s.name, s.pinned) } for _, name := range noMeta { fmt.Fprintf(opts.IO.ErrOut, "%s %s has no GitHub metadata — reinstall to enable updates\n", cs.WarningIcon(), name) @@ -339,7 +344,7 @@ func updateRun(opts *updateOptions) error { } else { fmt.Fprintf(opts.IO.Out, " %s %s (%s/%s) %s → %s [%s]\n", cs.Cyan("•"), u.local.name, u.local.owner, u.local.repo, - cs.Gray(git.ShortSHA(u.local.treeSHA)), git.ShortSHA(u.newSHA), + cs.Muted(git.ShortSHA(u.local.treeSHA)), git.ShortSHA(u.newSHA), u.resolved.Ref) } } @@ -359,7 +364,7 @@ func updateRun(opts *updateOptions) error { } if !confirmed { fmt.Fprintf(opts.IO.ErrOut, "Update cancelled.\n") - return nil + return cmdutil.CancelError } } @@ -409,9 +414,9 @@ func updateRun(opts *updateOptions) error { return nil } -// scanAllHosts walks every known host directory (project + user scope) and +// scanAllAgents walks every registered agent's skill directory (project + user scope) and // collects installed skills. Skills are deduplicated by directory path. -func scanAllHosts(gitRoot, homeDir string) []installedSkill { +func scanAllAgents(gitRoot, homeDir string) []installedSkill { seen := make(map[string]bool) var all []installedSkill @@ -533,28 +538,3 @@ func promptForSkillOrigin(p prompter.Prompter, skillName string) (owner, repo, r } return r.RepoOwner(), r.RepoName(), "", true, nil } - -func resolveGitRoot(gc *git.Client) string { - if gc == nil { - if cwd, err := os.Getwd(); err == nil { - return cwd - } - return "" - } - root, err := gc.ToplevelDir(context.Background()) - if err != nil { - if cwd, err := os.Getwd(); err == nil { - return cwd - } - return "" - } - return root -} - -func resolveHomeDir() string { - home, err := os.UserHomeDir() - if err != nil { - return "" - } - return home -} diff --git a/pkg/cmd/skills/update/update_test.go b/pkg/cmd/skills/update/update_test.go index 735536b0d..81fc87efe 100644 --- a/pkg/cmd/skills/update/update_test.go +++ b/pkg/cmd/skills/update/update_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "testing" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" @@ -43,14 +44,14 @@ func TestNewCmdUpdate_Flags(t *testing.T) { f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} cmd := NewCmdUpdate(f, func(_ *updateOptions) error { return nil }) - flags := []string{"all", "force", "dry-run", "dir"} + flags := []string{"all", "force", "dry-run", "dir", "unpin"} for _, name := range flags { assert.NotNil(t, cmd.Flags().Lookup(name), "missing flag: --%s", name) } } func TestNewCmdUpdate_ArgsPassedToOptions(t *testing.T) { - ios, _, _, _ := iostreams.Test() + ios, _, stdout, stderr := iostreams.Test() f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} var gotOpts *updateOptions @@ -61,8 +62,8 @@ func TestNewCmdUpdate_ArgsPassedToOptions(t *testing.T) { args, _ := shlex.Split("mcp-cli git-commit --all --force") cmd.SetArgs(args) - cmd.SetOut(os.Stdout) - cmd.SetErr(os.Stderr) + cmd.SetOut(stdout) + cmd.SetErr(stderr) err := cmd.Execute() require.NoError(t, err) assert.Equal(t, []string{"mcp-cli", "git-commit"}, gotOpts.Skills) @@ -71,321 +72,1072 @@ func TestNewCmdUpdate_ArgsPassedToOptions(t *testing.T) { } func TestScanInstalledSkills(t *testing.T) { - dir := t.TempDir() + tests := []struct { + name string + setup func(t *testing.T, dir string) + verify func(t *testing.T, skills []installedSkill, err error) + }{ + { + name: "happy path with metadata, no metadata, and pinned skills", + setup: func(t *testing.T, dir string) { + t.Helper() - skillDir := filepath.Join(dir, "git-commit") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - content := "---\nname: git-commit\ndescription: Git commit helper\nmetadata:\n github-owner: github\n github-repo: awesome-copilot\n github-tree-sha: abc123\n github-path: skills/git-commit\n---\nBody content\n" - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) + // Skill with full metadata + skillDir := filepath.Join(dir, "git-commit") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + content := heredoc.Doc(` + --- + name: git-commit + description: Git commit helper + metadata: + github-owner: monalisa + github-repo: awesome-copilot + github-tree-sha: abc123 + github-path: skills/git-commit + --- + Body content + `) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) - noMetaDir := filepath.Join(dir, "unknown-skill") - require.NoError(t, os.MkdirAll(noMetaDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(noMetaDir, "SKILL.md"), []byte("---\nname: unknown-skill\n---\nNo metadata here\n"), 0o644)) + // Skill without metadata + noMetaDir := filepath.Join(dir, "unknown-skill") + require.NoError(t, os.MkdirAll(noMetaDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(noMetaDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: unknown-skill + --- + No metadata here + `)), 0o644)) - pinnedDir := filepath.Join(dir, "pinned-skill") - require.NoError(t, os.MkdirAll(pinnedDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(pinnedDir, "SKILL.md"), []byte("---\nname: pinned-skill\nmetadata:\n github-owner: octo\n github-repo: skills\n github-tree-sha: def456\n github-pinned: v1.0.0\n---\nPinned content\n"), 0o644)) + // Pinned skill + pinnedDir := filepath.Join(dir, "pinned-skill") + require.NoError(t, os.MkdirAll(pinnedDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(pinnedDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: pinned-skill + metadata: + github-owner: octocat + github-repo: hubot-skills + github-tree-sha: def456 + github-pinned: v1.0.0 + --- + Pinned content + `)), 0o644)) + }, + verify: func(t *testing.T, skills []installedSkill, err error) { + t.Helper() + require.NoError(t, err) + assert.Len(t, skills, 3) - skills, err := scanInstalledSkills(dir, nil, "") - require.NoError(t, err) - assert.Len(t, skills, 3) + byName := make(map[string]installedSkill) + for _, s := range skills { + byName[s.name] = s + } - byName := make(map[string]installedSkill) - for _, s := range skills { - byName[s.name] = s - } + gc := byName["git-commit"] + assert.Equal(t, "monalisa", gc.owner) + assert.Equal(t, "awesome-copilot", gc.repo) + assert.Equal(t, "abc123", gc.treeSHA) + assert.Equal(t, "skills/git-commit", gc.sourcePath) + assert.Empty(t, gc.pinned) - gc := byName["git-commit"] - assert.Equal(t, "github", gc.owner) - assert.Equal(t, "awesome-copilot", gc.repo) - assert.Equal(t, "abc123", gc.treeSHA) - assert.Equal(t, "skills/git-commit", gc.sourcePath) - assert.Empty(t, gc.pinned) + us := byName["unknown-skill"] + assert.Empty(t, us.owner) + assert.Empty(t, us.repo) - us := byName["unknown-skill"] - assert.Empty(t, us.owner) - assert.Empty(t, us.repo) - - ps := byName["pinned-skill"] - assert.Equal(t, "v1.0.0", ps.pinned) -} - -func TestScanInstalledSkills_NonExistentDir(t *testing.T) { - skills, err := scanInstalledSkills("/nonexistent/path", nil, "") - require.NoError(t, err) - assert.Nil(t, skills) -} - -func TestScanInstalledSkills_CorruptedYAML(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "corrupt") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nnot: valid: yaml: [broken\n---\nbody\n"), 0o644)) - - skills, err := scanInstalledSkills(dir, nil, "") - require.NoError(t, err) - assert.Len(t, skills, 0) -} - -func TestPromptForSkillOrigin_Valid(t *testing.T) { - pm := &prompter.PrompterMock{ - InputFunc: func(prompt string, defaultValue string) (string, error) { - return "github/awesome-copilot", nil + ps := byName["pinned-skill"] + assert.Equal(t, "v1.0.0", ps.pinned) + }, + }, + { + name: "non-existent directory returns nil", + // no setup — dir does not exist + verify: func(t *testing.T, skills []installedSkill, err error) { + t.Helper() + require.NoError(t, err) + assert.Nil(t, skills) + }, + }, + { + name: "corrupted YAML is skipped gracefully", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "corrupt") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + not: valid: yaml: [broken + --- + body + `)), 0o644)) + }, + verify: func(t *testing.T, skills []installedSkill, err error) { + t.Helper() + require.NoError(t, err) + assert.Len(t, skills, 0) + }, }, } - owner, repo, _, ok, err := promptForSkillOrigin(pm, "test-skill") - require.NoError(t, err) - assert.True(t, ok) - assert.Equal(t, "github", owner) - assert.Equal(t, "awesome-copilot", repo) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // For the non-existent directory case, pass a path that doesn't exist + dir := filepath.Join(t.TempDir(), "skills") + if tt.setup != nil { + require.NoError(t, os.MkdirAll(dir, 0o755)) + tt.setup(t, dir) + } + + skills, err := scanInstalledSkills(dir, nil, "") + tt.verify(t, skills, err) + }) + } } -func TestPromptForSkillOrigin_Empty(t *testing.T) { - pm := &prompter.PrompterMock{ - InputFunc: func(prompt string, defaultValue string) (string, error) { - return "", nil +func TestPromptForSkillOrigin(t *testing.T) { + tests := []struct { + name string + input string + wantOK bool + wantOwner string + wantRepo string + wantReason string + }{ + { + name: "valid owner/repo", + input: "monalisa/awesome-copilot", + wantOK: true, + wantOwner: "monalisa", + wantRepo: "awesome-copilot", + }, + { + name: "empty input skips", + input: "", + wantOK: false, + }, + { + name: "invalid format returns reason", + input: "just-a-name", + wantOK: false, + wantReason: "invalid repository", }, } - _, _, _, ok, err := promptForSkillOrigin(pm, "test-skill") - require.NoError(t, err) - assert.False(t, ok) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pm := &prompter.PrompterMock{ + InputFunc: func(prompt string, defaultValue string) (string, error) { + return tt.input, nil + }, + } + + owner, repo, reason, ok, err := promptForSkillOrigin(pm, "test-skill") + require.NoError(t, err) + assert.Equal(t, tt.wantOK, ok) + assert.Equal(t, tt.wantOwner, owner) + assert.Equal(t, tt.wantRepo, repo) + if tt.wantReason != "" { + assert.Contains(t, reason, tt.wantReason) + } + }) + } } -func TestPromptForSkillOrigin_Invalid(t *testing.T) { - pm := &prompter.PrompterMock{ - InputFunc: func(prompt string, defaultValue string) (string, error) { - return "just-a-name", nil +func TestUpdateRun(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, dir string) + stubs func(reg *httpmock.Registry) + opts func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions + verify func(t *testing.T, dir string) + wantErr string + wantStderr string + wantStdout string + }{ + { + name: "scans all agents when no --dir is set", + setup: func(t *testing.T, dir string) { + t.Helper() + t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) + skillDir := filepath.Join(dir, ".github", "skills", "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-owner: monalisa + github-repo: octocat-skills + github-tree-sha: currentsha + github-path: skills/code-review + --- + Installed content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "commit1", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/commit1"), + httpmock.StringResponse(`{"sha": "commit1", "tree": [{"path": "skills/code-review", "type": "tree", "sha": "currentsha"}, {"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "blob1"}, {"path": "skills", "type": "tree", "sha": "treeshaX"}], "truncated": false}`)) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(false) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + } + }, + wantStderr: "All skills are up to date.", + }, + { + name: "no installed skills", + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(false) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantStderr: "No installed skills found.", + }, + { + name: "specific skill not installed", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "octocat-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: octocat-skill + metadata: + github-owner: octocat + github-repo: hubot-skills + github-tree-sha: abc + --- + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(false) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + Skills: []string{"nonexistent"}, + } + }, + wantErr: "none of the specified skills are installed", + }, + { + name: "pinned skills are skipped", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "pinned-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: pinned-skill + metadata: + github-owner: octocat + github-repo: hubot-skills + github-tree-sha: abc123 + github-pinned: v1.0.0 + --- + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantStderr: "pinned", + }, + { + name: "no metadata skips in non-interactive mode", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "manual-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: manual-skill + --- + No metadata + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantStderr: "no GitHub metadata", + }, + { + name: "all up to date", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "monalisa-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: monalisa-skill + metadata: + github-owner: monalisa + github-repo: octocat-skills + github-tree-sha: abc123def456 + github-path: skills/monalisa-skill + --- + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "commitsha123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/commitsha123"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "commitsha123", "tree": [{"path": "skills/monalisa-skill/SKILL.md", "type": "blob", "sha": "blobsha1"}, {"path": "skills/monalisa-skill", "type": "tree", "sha": "abc123def456"}, {"path": "skills", "type": "tree", "sha": "treeshaX"}], "truncated": false}`)), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(false) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantStderr: "All skills are up to date.", + }, + { + name: "dry run reports available updates", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "hubot-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: hubot-skill + metadata: + github-owner: hubot + github-repo: octocat-skills + github-tree-sha: oldsha123 + github-path: skills/hubot-skill + --- + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/hubot/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v2.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/hubot/octocat-skills/git/ref/tags/v2.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit456", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/hubot/octocat-skills/git/trees/newcommit456"), + httpmock.StringResponse(`{"sha": "newcommit456", "tree": [{"path": "skills/hubot-skill/SKILL.md", "type": "blob", "sha": "blobsha2"}, {"path": "skills/hubot-skill", "type": "tree", "sha": "newsha456"}, {"path": "skills", "type": "tree", "sha": "treeshaY"}], "truncated": false}`), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + DryRun: true, + } + }, + wantStderr: "1 update(s) available:", + wantStdout: "hubot-skill", + }, + { + name: "non-interactive without --all errors", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "hubot-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: hubot-skill + metadata: + github-owner: hubot + github-repo: octocat-skills + github-tree-sha: oldsha123 + github-path: skills/hubot-skill + --- + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/hubot/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v2.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/hubot/octocat-skills/git/ref/tags/v2.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit456", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/hubot/octocat-skills/git/trees/newcommit456"), + httpmock.StringResponse(`{"sha": "newcommit456", "tree": [{"path": "skills/hubot-skill/SKILL.md", "type": "blob", "sha": "blobsha2"}, {"path": "skills/hubot-skill", "type": "tree", "sha": "newsha456"}, {"path": "skills", "type": "tree", "sha": "treeshaY"}], "truncated": false}`), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantErr: "updates available; re-run with --all to apply, or run interactively to confirm", + }, + { + name: "force update rewrites SKILL.md on disk", + setup: func(t *testing.T, dir string) { + t.Helper() + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + skillDir := filepath.Join(dir, "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-owner: monalisa + github-repo: octocat-skills + github-tree-sha: oldsha000 + github-path: skills/code-review + --- + Old content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newsha999"), + httpmock.StringResponse(`{"sha": "newsha999", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "newblob1", "size": 20}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/newblob1"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, + "IyBDb2RlIFJldmlldyBVcGRhdGVk"))) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(false) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + All: true, + Force: true, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "code-review", "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(content), "github-owner: monalisa") + assert.NotContains(t, string(content), "Old content") + }, + wantStdout: "Updated code-review", + }, + { + name: "namespaced skill with --dir resolves install base correctly", + setup: func(t *testing.T, dir string) { + t.Helper() + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + skillDir := filepath.Join(dir, "monalisa", "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-owner: monalisa + github-repo: octocat-skills + github-tree-sha: oldsha000 + github-path: skills/monalisa/code-review + --- + Old namespaced content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/monalisa/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/monalisa/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills/monalisa", "type": "tree", "sha": "nstresha"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newsha999"), + httpmock.StringResponse(`{"sha": "newsha999", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "newblob1", "size": 20}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/newblob1"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, + "IyBOYW1lc3BhY2VkIFNraWxsIFVwZGF0ZWQ="))) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(false) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + All: true, + Force: true, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "monalisa", "code-review", "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(content), "github-owner: monalisa") + assert.NotContains(t, string(content), "Old namespaced content") + }, + wantStdout: "Updated monalisa/code-review", + }, + { + name: "install failure during update reports error and continues", + setup: func(t *testing.T, dir string) { + t.Helper() + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + skillDir := filepath.Join(dir, "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-owner: monalisa + github-repo: octocat-skills + github-tree-sha: oldsha000 + github-path: skills/code-review + --- + Original content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newsha999"), + httpmock.StringResponse(`{"sha": "newsha999", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "newblob1", "size": 20}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/newblob1"), + httpmock.StatusStringResponse(500, "server error")) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(false) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + All: true, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "code-review", "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(content), "Original content", "file should not be modified on failure") + }, + wantStderr: "Failed to update code-review", + wantErr: "SilentError", + }, + { + name: "interactive confirm applies update", + setup: func(t *testing.T, dir string) { + t.Helper() + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + skillDir := filepath.Join(dir, "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-owner: monalisa + github-repo: octocat-skills + github-tree-sha: oldsha000 + github-path: skills/code-review + --- + Old content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newsha999"), + httpmock.StringResponse(`{"sha": "newsha999", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "newblob1", "size": 20}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/newblob1"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, + "IyBDb2RlIFJldmlldyBVcGRhdGVk"))) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, defaultVal bool) (bool, error) { + return true, nil + }, + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "code-review", "SKILL.md")) + require.NoError(t, err) + assert.NotContains(t, string(content), "Old content") + }, + wantStdout: "Updated code-review", + }, + { + name: "interactive confirm cancelled", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-owner: monalisa + github-repo: octocat-skills + github-tree-sha: oldsha000 + github-path: skills/code-review + --- + Old content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, defaultVal bool) (bool, error) { + return false, nil + }, + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantErr: "CancelError", + wantStderr: "Update cancelled", + }, + { + name: "no-metadata skill prompted interactively and skipped", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "manual-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: manual-skill + --- + No metadata + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{ + InputFunc: func(prompt string, defaultValue string) (string, error) { + return "", nil + }, + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantStderr: "no GitHub metadata", + }, + { + name: "no-metadata skill enriched via prompt then updated", + setup: func(t *testing.T, dir string) { + t.Helper() + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + skillDir := filepath.Join(dir, "manual-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: manual-skill + --- + Old manual content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "commit123", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/commit123"), + httpmock.StringResponse(`{"sha": "commit123", "tree": [{"path": "skills/manual-skill/SKILL.md", "type": "blob", "sha": "blob1"}, {"path": "skills/manual-skill", "type": "tree", "sha": "newtree1"}, {"path": "skills", "type": "tree", "sha": "treeshaX"}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newtree1"), + httpmock.StringResponse(`{"sha": "newtree1", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "blob1", "size": 20}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob1"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blob1", "encoding": "base64", "content": "%s"}`, + "IyBNYW51YWwgU2tpbGwgVXBkYXRlZA=="))) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{ + InputFunc: func(prompt string, defaultValue string) (string, error) { + return "monalisa/octocat-skills", nil + }, + ConfirmFunc: func(msg string, defaultVal bool) (bool, error) { + return true, nil + }, + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "manual-skill", "SKILL.md")) + require.NoError(t, err) + assert.NotContains(t, string(content), "Old manual content") + assert.Contains(t, string(content), "github-owner: monalisa") + }, + wantStdout: "Updated manual-skill", + }, + { + name: "unpin clears pin and applies update", + setup: func(t *testing.T, dir string) { + t.Helper() + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + skillDir := filepath.Join(dir, "pinned-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: pinned-skill + metadata: + github-owner: octocat + github-repo: hubot-skills + github-tree-sha: oldsha000 + github-pinned: v1.0.0 + github-path: skills/pinned-skill + --- + Pinned content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v2.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/git/ref/tags/v2.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/pinned-skill/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/pinned-skill", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/git/trees/newsha999"), + httpmock.StringResponse(`{"sha": "newsha999", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "newblob1", "size": 20}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/git/blobs/newblob1"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, + "IyBVbnBpbm5lZCBhbmQgVXBkYXRlZA=="))) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(false) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + All: true, + Unpin: true, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "pinned-skill", "SKILL.md")) + require.NoError(t, err) + assert.NotContains(t, string(content), "Pinned content") + assert.NotContains(t, string(content), "github-pinned") + }, + wantStdout: "Updated pinned-skill", + }, + { + name: "pinned skills still skipped without --unpin", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "pinned-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: pinned-skill + metadata: + github-owner: octocat + github-repo: hubot-skills + github-tree-sha: abc123 + github-pinned: v1.0.0 + --- + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + Unpin: false, + } + }, + wantStderr: "pinned", + }, + { + name: "unpin with dry-run reports update without modifying files", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "pinned-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: pinned-skill + metadata: + github-owner: octocat + github-repo: hubot-skills + github-tree-sha: oldsha000 + github-pinned: v1.0.0 + github-path: skills/pinned-skill + --- + Pinned content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v2.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/git/ref/tags/v2.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/pinned-skill/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/pinned-skill", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + DryRun: true, + Unpin: true, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "pinned-skill", "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(content), "github-pinned: v1.0.0", "dry-run should not modify files") + }, + wantStderr: "1 update(s) available:", + wantStdout: "pinned-skill", }, } - _, _, reason, ok, err := promptForSkillOrigin(pm, "test-skill") - require.NoError(t, err) - assert.False(t, ok) - assert.Contains(t, reason, "invalid repository") -} -func TestUpdateRun_NoInstalledSkills(t *testing.T) { - ios, _, _, stderr := iostreams.Test() - ios.SetStdoutTTY(false) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, stdout, stderr := iostreams.Test() - dir := t.TempDir() + dir := t.TempDir() + if tt.setup != nil { + tt.setup(t, dir) + } - reg := &httpmock.Registry{} - opts := &updateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, + reg := &httpmock.Registry{} + defer reg.Verify(t) + if tt.stubs != nil { + tt.stubs(reg) + } + + opts := tt.opts(ios, dir, reg) + err := updateRun(opts) + + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + return + } + require.NoError(t, err) + if tt.wantStderr != "" { + assert.Contains(t, stderr.String(), tt.wantStderr) + } + if tt.wantStdout != "" { + assert.Contains(t, stdout.String(), tt.wantStdout) + } + if tt.verify != nil { + tt.verify(t, dir) + } + }) } - - defer reg.Verify(t) - err := updateRun(opts) - require.NoError(t, err) - assert.Contains(t, stderr.String(), "No installed skills found.") -} - -func TestUpdateRun_SpecificSkillNotInstalled(t *testing.T) { - ios, _, _, _ := iostreams.Test() - ios.SetStdoutTTY(false) - - dir := t.TempDir() - skillDir := filepath.Join(dir, "existing-skill") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: existing-skill\nmetadata:\n github-owner: owner\n github-repo: repo\n github-tree-sha: abc\n---\n"), 0o644)) - - reg := &httpmock.Registry{} - opts := &updateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, - Skills: []string{"nonexistent"}, - } - - defer reg.Verify(t) - err := updateRun(opts) - assert.EqualError(t, err, "none of the specified skills are installed") -} - -func TestUpdateRun_PinnedSkillsSkipped(t *testing.T) { - ios, _, _, stderr := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStderrTTY(true) - - dir := t.TempDir() - skillDir := filepath.Join(dir, "pinned-skill") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: pinned-skill\nmetadata:\n github-owner: owner\n github-repo: repo\n github-tree-sha: abc123\n github-pinned: v1.0.0\n---\n"), 0o644)) - - reg := &httpmock.Registry{} - opts := &updateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - Prompter: &prompter.PrompterMock{}, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, - } - - defer reg.Verify(t) - err := updateRun(opts) - require.NoError(t, err) - assert.Contains(t, stderr.String(), "pinned-skill is pinned to v1.0.0 (skipped)") - assert.Contains(t, stderr.String(), "All skills are up to date.") -} - -func TestUpdateRun_NoMetaSkipsNonInteractive(t *testing.T) { - ios, _, _, stderr := iostreams.Test() - ios.SetStdoutTTY(false) - ios.SetStdinTTY(false) - - dir := t.TempDir() - skillDir := filepath.Join(dir, "manual-skill") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: manual-skill\n---\nNo metadata\n"), 0o644)) - - reg := &httpmock.Registry{} - opts := &updateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, - } - - defer reg.Verify(t) - err := updateRun(opts) - require.NoError(t, err) - assert.Contains(t, stderr.String(), "manual-skill has no GitHub metadata") -} - -func TestUpdateRun_AllUpToDate(t *testing.T) { - ios, _, _, stderr := iostreams.Test() - ios.SetStdoutTTY(false) - - dir := t.TempDir() - skillDir := filepath.Join(dir, "my-skill") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: my-skill\nmetadata:\n github-owner: octo\n github-repo: skills\n github-tree-sha: abc123def456\n github-path: skills/my-skill\n---\n"), 0o644)) - - reg := &httpmock.Registry{} - reg.Register( - httpmock.REST("GET", "repos/octo/skills/releases/latest"), - httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), - ) - reg.Register( - httpmock.REST("GET", "repos/octo/skills/git/ref/tags/v1.0.0"), - httpmock.StringResponse(`{"object": {"sha": "commitsha123", "type": "commit"}}`), - ) - reg.Register( - httpmock.REST("GET", fmt.Sprintf("repos/octo/skills/git/trees/commitsha123")), - httpmock.StringResponse(`{"sha": "commitsha123", "tree": [{"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobsha1"}, {"path": "skills/my-skill", "type": "tree", "sha": "abc123def456"}, {"path": "skills", "type": "tree", "sha": "treeshaX"}], "truncated": false}`), - ) - - opts := &updateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, - } - - defer reg.Verify(t) - err := updateRun(opts) - require.NoError(t, err) - assert.Contains(t, stderr.String(), "All skills are up to date.") -} - -func TestUpdateRun_DryRun(t *testing.T) { - ios, _, stdout, stderr := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStderrTTY(true) - - dir := t.TempDir() - skillDir := filepath.Join(dir, "my-skill") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: my-skill\nmetadata:\n github-owner: octo\n github-repo: skills\n github-tree-sha: oldsha123\n github-path: skills/my-skill\n---\n"), 0o644)) - - reg := &httpmock.Registry{} - reg.Register( - httpmock.REST("GET", "repos/octo/skills/releases/latest"), - httpmock.StringResponse(`{"tag_name": "v2.0.0"}`), - ) - reg.Register( - httpmock.REST("GET", "repos/octo/skills/git/ref/tags/v2.0.0"), - httpmock.StringResponse(`{"object": {"sha": "newcommit456", "type": "commit"}}`), - ) - reg.Register( - httpmock.REST("GET", "repos/octo/skills/git/trees/newcommit456"), - httpmock.StringResponse(`{"sha": "newcommit456", "tree": [{"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobsha2"}, {"path": "skills/my-skill", "type": "tree", "sha": "newsha456"}, {"path": "skills", "type": "tree", "sha": "treeshaY"}], "truncated": false}`), - ) - - opts := &updateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - Prompter: &prompter.PrompterMock{}, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, - DryRun: true, - } - - defer reg.Verify(t) - err := updateRun(opts) - require.NoError(t, err) - assert.Contains(t, stderr.String(), "1 update(s) available:") - assert.Contains(t, stdout.String(), "my-skill") - assert.Contains(t, stdout.String(), "octo/skills") -} - -func TestUpdateRun_NonInteractiveNoAll(t *testing.T) { - ios, _, _, _ := iostreams.Test() - ios.SetStdoutTTY(false) - ios.SetStdinTTY(false) - - dir := t.TempDir() - skillDir := filepath.Join(dir, "my-skill") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: my-skill\nmetadata:\n github-owner: octo\n github-repo: skills\n github-tree-sha: oldsha123\n github-path: skills/my-skill\n---\n"), 0o644)) - - reg := &httpmock.Registry{} - reg.Register( - httpmock.REST("GET", "repos/octo/skills/releases/latest"), - httpmock.StringResponse(`{"tag_name": "v2.0.0"}`), - ) - reg.Register( - httpmock.REST("GET", "repos/octo/skills/git/ref/tags/v2.0.0"), - httpmock.StringResponse(`{"object": {"sha": "newcommit456", "type": "commit"}}`), - ) - reg.Register( - httpmock.REST("GET", "repos/octo/skills/git/trees/newcommit456"), - httpmock.StringResponse(`{"sha": "newcommit456", "tree": [{"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobsha2"}, {"path": "skills/my-skill", "type": "tree", "sha": "newsha456"}, {"path": "skills", "type": "tree", "sha": "treeshaY"}], "truncated": false}`), - ) - - opts := &updateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, - } - - defer reg.Verify(t) - err := updateRun(opts) - assert.EqualError(t, err, "updates available; re-run with --all to apply, or run interactively to confirm") } From ba61ded4b30a361b65285d81fd8e8021edf156da Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Wed, 8 Apr 2026 12:07:59 +0100 Subject: [PATCH 06/27] use markdown renderer in preview when previewing multi-file skills --- pkg/cmd/skills/preview/preview.go | 64 ++++++++++++++++---- pkg/cmd/skills/preview/preview_test.go | 84 ++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 13 deletions(-) diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index f06d4c182..ce7c49ef2 100644 --- a/pkg/cmd/skills/preview/preview.go +++ b/pkg/cmd/skills/preview/preview.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "net/http" + "path" "sort" "strings" @@ -24,6 +25,7 @@ type previewOptions struct { HttpClient func() (*http.Client, error) Prompter prompter.Prompter Executable func() string + RenderFile func(string, string) string RepoArg string SkillName string @@ -39,6 +41,9 @@ func NewCmdPreview(f *cmdutil.Factory, runF func(*previewOptions) error) *cobra. Prompter: f.Prompter, Executable: f.Executable, } + opts.RenderFile = func(filePath, content string) string { + return renderMarkdownPreview(opts.IO, filePath, content) + } cmd := &cobra.Command{ Use: "preview []", @@ -139,18 +144,7 @@ func previewRun(opts *previewOptions) error { return err } - parsed, parseErr := frontmatter.Parse(content) - if parseErr == nil { - content = parsed.Body - } - - rendered, err := markdown.Render(content, - markdown.WithTheme(opts.IO.TerminalTheme()), - markdown.WithWrap(opts.IO.TerminalWidth()), - markdown.WithoutIndentation()) - if err != nil { - rendered = content - } + rendered := opts.renderFile("SKILL.md", content) // Collect extra files (everything that isn't SKILL.md) var extraFiles []discovery.SkillFile @@ -268,7 +262,7 @@ func renderInteractive(opts *previewOptions, cs *iostreams.ColorScheme, skill di fmt.Fprintf(opts.IO.ErrOut, "%s could not fetch %s: %v\n", cs.Red("!"), selectedFile.Path, fetchErr) continue } - content = fileContent + content = renderSelectedFilePreview(opts, selectedFile.Path, fileContent) if !strings.HasSuffix(content, "\n") { content += "\n" } @@ -282,6 +276,50 @@ func renderInteractive(opts *previewOptions, cs *iostreams.ColorScheme, skill di } } +func (opts *previewOptions) renderFile(filePath, content string) string { + if opts.RenderFile != nil { + return opts.RenderFile(filePath, content) + } + + return renderMarkdownPreview(opts.IO, filePath, content) +} + +func renderSelectedFilePreview(opts *previewOptions, filePath, content string) string { + if !isMarkdownFile(filePath) { + return content + } + + return opts.renderFile(filePath, content) +} + +func renderMarkdownPreview(io *iostreams.IOStreams, filePath, content string) string { + if filePath == "SKILL.md" { + parsed, err := frontmatter.Parse(content) + if err == nil { + content = parsed.Body + } + } + + rendered, err := markdown.Render(content, + markdown.WithTheme(io.TerminalTheme()), + markdown.WithWrap(io.TerminalWidth()), + markdown.WithoutIndentation()) + if err != nil { + return content + } + + return rendered +} + +func isMarkdownFile(filePath string) bool { + switch strings.ToLower(path.Ext(filePath)) { + case ".md", ".markdown", ".mdown", ".mkd", ".mkdn": + return true + default: + return false + } +} + func selectSkill(opts *previewOptions, skills []discovery.Skill) (discovery.Skill, error) { if opts.SkillName != "" { for _, s := range skills { diff --git a/pkg/cmd/skills/preview/preview_test.go b/pkg/cmd/skills/preview/preview_test.go index 4c3864809..0cbea6ae7 100644 --- a/pkg/cmd/skills/preview/preview_test.go +++ b/pkg/cmd/skills/preview/preview_test.go @@ -445,6 +445,90 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { assert.Equal(t, 2, selectCalls) }) + t.Run("interactive markdown file uses markdown renderer", func(t *testing.T) { + readmeContent := "# Usage\n\nUse **carefully**." + encodedReadme := base64.StdEncoding.EncodeToString([]byte(readmeContent)) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(`{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": "skills/my-skill", "type": "tree", "sha": "treeSHA"}, + {"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobSKILL"}, + {"path": "skills/my-skill/README.md", "type": "blob", "sha": "blobREADME"} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blobSKILL", "size": 50}, + {"path": "README.md", "type": "blob", "sha": "blobREADME", "size": 28} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blobSKILL"), + httpmock.StringResponse(`{"sha": "blobSKILL", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blobREADME"), + httpmock.StringResponse(`{"sha": "blobREADME", "content": "`+encodedReadme+`", "encoding": "base64"}`), + ) + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetColorEnabled(false) + + renderCalls := 0 + + selectCalls := 0 + pm := &prompter.PrompterMock{ + SelectFunc: func(prompt string, defaultValue string, options []string) (int, error) { + selectCalls++ + if selectCalls == 1 { + assert.Equal(t, []string{"SKILL.md", "README.md"}, options) + return 1, nil + } + return 0, fmt.Errorf("user cancelled") + }, + } + + opts := &previewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + repo: ghrepo.New("owner", "repo"), + SkillName: "my-skill", + RenderFile: func(filePath, content string) string { + renderCalls++ + return fmt.Sprintf("rendered:%s", filePath) + }, + } + + err := previewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "rendered:README.md") + assert.Equal(t, 2, selectCalls) + assert.Equal(t, 2, renderCalls) + }) + t.Run("non-interactive dumps all files", func(t *testing.T) { reg := makeReg() defer reg.Verify(t) From b26256a10d486de0abc573fb5f9f423e32c59ce5 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Wed, 8 Apr 2026 12:17:31 +0100 Subject: [PATCH 07/27] show loading spinner during installation, even for multi-file skills --- internal/skills/installer/installer.go | 4 +-- internal/skills/installer/installer_test.go | 36 +++++++++++++++++++++ pkg/cmd/skills/install/install.go | 6 ++-- pkg/cmd/skills/install/install_test.go | 8 +++++ 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/internal/skills/installer/installer.go b/internal/skills/installer/installer.go index 8ae3da28f..ce5370004 100644 --- a/internal/skills/installer/installer.go +++ b/internal/skills/installer/installer.go @@ -69,6 +69,7 @@ func Install(opts *Options) (*Result, error) { skill := opts.Skills[0] if opts.OnProgress != nil { opts.OnProgress(0, 1) + defer opts.OnProgress(1, 1) } if err := installSkill(opts, skill, targetDir); err != nil { return nil, fmt.Errorf("failed to install skill %q: %w", skill.InstallName(), err) @@ -77,9 +78,6 @@ func Install(opts *Options) (*Result, error) { if err := lockfile.RecordInstall(skill.InstallName(), opts.Owner, opts.Repo, skill.Path+"/SKILL.md", skill.TreeSHA, opts.PinnedRef); err != nil { warnings = append(warnings, fmt.Sprintf("could not record install for %s: %v", skill.InstallName(), err)) } - if opts.OnProgress != nil { - opts.OnProgress(1, 1) - } return &Result{Installed: []string{skill.InstallName()}, Dir: targetDir, Warnings: warnings}, nil } diff --git a/internal/skills/installer/installer_test.go b/internal/skills/installer/installer_test.go index 0637e9c19..ea9c619c2 100644 --- a/internal/skills/installer/installer_test.go +++ b/internal/skills/installer/installer_test.go @@ -447,6 +447,42 @@ func TestInstall(t *testing.T) { } } +func TestInstallSingleSkillFailureStillCompletesProgress(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + destDir := t.TempDir() + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-fail"), + httpmock.StatusStringResponse(500, "server error"), + ) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + var events []struct{ done, total int } + result, err := Install(&Options{ + Host: "github.com", + Owner: "monalisa", + Repo: "octocat-skills", + Ref: "v1.0", + SHA: "commit123", + Client: client, + Skills: []discovery.Skill{ + {Name: "code-review", Path: "skills/code-review", TreeSHA: "tree-fail"}, + }, + Dir: destDir, + OnProgress: func(done, total int) { + events = append(events, struct{ done, total int }{done: done, total: total}) + }, + }) + + require.Error(t, err) + assert.Nil(t, result) + assert.Equal(t, []struct{ done, total int }{{done: 0, total: 1}, {done: 1, total: 1}}, events) +} + func TestResolveGitRoot(t *testing.T) { tests := []struct { name string diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index 8188c8f3f..149d682eb 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -907,13 +907,15 @@ func existingSkillPrompt(targetDir string, incoming discovery.Skill) string { return fmt.Sprintf("Skill %q already exists. Overwrite?", incoming.DisplayName()) } +const installProgressLabel = "Downloading skill files" + func installProgress(io *iostreams.IOStreams, total int) func(done, total int) { - if total <= 1 { + if total <= 0 { return nil } return func(done, total int) { if done == 0 { - io.StartProgressIndicator() + io.StartProgressIndicatorWithLabel(installProgressLabel) } else if done >= total { io.StopProgressIndicator() } diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index a4f67f1f1..84fb24b7a 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -1324,6 +1324,14 @@ func TestInstallRun(t *testing.T) { } } +func TestInstallProgress(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + assert.Nil(t, installProgress(ios, 0)) + assert.NotNil(t, installProgress(ios, 1)) + assert.NotNil(t, installProgress(ios, 2)) +} + func TestRunLocalInstall(t *testing.T) { tests := []struct { name string From 3b50bbbf16adc6beeb2c8f968dc30cbd0b3d76cd Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Wed, 8 Apr 2026 14:52:14 +0100 Subject: [PATCH 08/27] add .agents/skills as default installation path for hosts that support it: cursor, codex, gemini CLI, github copilot, antigravity. excluded: claude code --- internal/skills/registry/registry.go | 12 +- internal/skills/registry/registry_test.go | 69 ++++++++--- pkg/cmd/skills/install/install.go | 134 +++++++++++++--------- pkg/cmd/skills/install/install_test.go | 41 +++++++ pkg/cmd/skills/publish/publish_test.go | 6 +- pkg/cmd/skills/update/update.go | 16 ++- pkg/cmd/skills/update/update_test.go | 45 +++++++- 7 files changed, 233 insertions(+), 90 deletions(-) diff --git a/internal/skills/registry/registry.go b/internal/skills/registry/registry.go index ecaaaa48d..a8fdc5993 100644 --- a/internal/skills/registry/registry.go +++ b/internal/skills/registry/registry.go @@ -27,6 +27,8 @@ type Scope string const ( ScopeProject Scope = "project" ScopeUser Scope = "user" + + sharedProjectSkillsDir = ".agents/skills" ) // Agents contains all known agent hosts. @@ -34,7 +36,7 @@ var Agents = []AgentHost{ { ID: "github-copilot", Name: "GitHub Copilot", - ProjectDir: ".github/skills", + ProjectDir: sharedProjectSkillsDir, UserDir: ".copilot/skills", }, { @@ -46,25 +48,25 @@ var Agents = []AgentHost{ { ID: "cursor", Name: "Cursor", - ProjectDir: ".cursor/skills", + ProjectDir: sharedProjectSkillsDir, UserDir: ".cursor/skills", }, { ID: "codex", Name: "Codex", - ProjectDir: ".agents/skills", + ProjectDir: sharedProjectSkillsDir, UserDir: ".codex/skills", }, { ID: "gemini", Name: "Gemini CLI", - ProjectDir: ".agent/skills", + ProjectDir: sharedProjectSkillsDir, UserDir: ".gemini/skills", }, { ID: "antigravity", Name: "Antigravity", - ProjectDir: ".agent/skills", + ProjectDir: sharedProjectSkillsDir, UserDir: ".gemini/antigravity/skills", }, } diff --git a/internal/skills/registry/registry_test.go b/internal/skills/registry/registry_test.go index e17668b87..003a28afa 100644 --- a/internal/skills/registry/registry_test.go +++ b/internal/skills/registry/registry_test.go @@ -38,11 +38,9 @@ func TestFindByID(t *testing.T) { } func TestInstallDir(t *testing.T) { - host, err := FindByID("github-copilot") - require.NoError(t, err) - tests := []struct { name string + hostID string scope Scope gitRoot string homeDir string @@ -50,21 +48,64 @@ func TestInstallDir(t *testing.T) { wantErr bool }{ { - name: "project scope", + name: "github copilot project scope", + hostID: "github-copilot", scope: ScopeProject, gitRoot: "/tmp/monalisa-repo", homeDir: "/home/monalisa", - wantDir: filepath.Join("/tmp/monalisa-repo", ".github", "skills"), + wantDir: filepath.Join("/tmp/monalisa-repo", ".agents", "skills"), }, { - name: "user scope", + name: "github copilot user scope", + hostID: "github-copilot", scope: ScopeUser, gitRoot: "/tmp/monalisa-repo", homeDir: "/home/monalisa", wantDir: filepath.Join("/home/monalisa", ".copilot", "skills"), }, + { + name: "claude code project scope", + hostID: "claude-code", + scope: ScopeProject, + gitRoot: "/tmp/monalisa-repo", + homeDir: "/home/monalisa", + wantDir: filepath.Join("/tmp/monalisa-repo", ".claude", "skills"), + }, + { + name: "cursor project scope", + hostID: "cursor", + scope: ScopeProject, + gitRoot: "/tmp/monalisa-repo", + homeDir: "/home/monalisa", + wantDir: filepath.Join("/tmp/monalisa-repo", ".agents", "skills"), + }, + { + name: "codex project scope", + hostID: "codex", + scope: ScopeProject, + gitRoot: "/tmp/monalisa-repo", + homeDir: "/home/monalisa", + wantDir: filepath.Join("/tmp/monalisa-repo", ".agents", "skills"), + }, + { + name: "gemini project scope", + hostID: "gemini", + scope: ScopeProject, + gitRoot: "/tmp/monalisa-repo", + homeDir: "/home/monalisa", + wantDir: filepath.Join("/tmp/monalisa-repo", ".agents", "skills"), + }, + { + name: "antigravity project scope", + hostID: "antigravity", + scope: ScopeProject, + gitRoot: "/tmp/monalisa-repo", + homeDir: "/home/monalisa", + wantDir: filepath.Join("/tmp/monalisa-repo", ".agents", "skills"), + }, { name: "project scope without git root", + hostID: "github-copilot", scope: ScopeProject, gitRoot: "", homeDir: "/home/monalisa", @@ -72,6 +113,7 @@ func TestInstallDir(t *testing.T) { }, { name: "user scope without home dir", + hostID: "github-copilot", scope: ScopeUser, gitRoot: "/tmp/monalisa-repo", homeDir: "", @@ -79,6 +121,7 @@ func TestInstallDir(t *testing.T) { }, { name: "invalid scope", + hostID: "github-copilot", scope: "bogus", gitRoot: "/tmp/monalisa-repo", homeDir: "/home/monalisa", @@ -87,6 +130,9 @@ func TestInstallDir(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + host, err := FindByID(tt.hostID) + require.NoError(t, err) + dir, err := host.InstallDir(tt.scope, tt.gitRoot, tt.homeDir) if tt.wantErr { assert.Error(t, err) @@ -121,16 +167,7 @@ func TestRepoNameFromRemote(t *testing.T) { func TestUniqueProjectDirs(t *testing.T) { dirs := UniqueProjectDirs() - require.NotEmpty(t, dirs) - - // Should deduplicate — e.g. gemini and antigravity share .agent/skills - seen := map[string]int{} - for _, d := range dirs { - seen[d]++ - } - for dir, count := range seen { - assert.Equalf(t, 1, count, "directory %q appears %d times, expected 1", dir, count) - } + assert.Equal(t, []string{".agents/skills", ".claude/skills"}, dirs) } func TestScopeLabels(t *testing.T) { diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index 149d682eb..b0ca4bf4a 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -82,17 +82,22 @@ func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra. scope (in your home directory, available everywhere): Host Project User - GitHub Copilot .github/skills ~/.copilot/skills + GitHub Copilot .agents/skills ~/.copilot/skills Claude Code .claude/skills ~/.claude/skills - Cursor .cursor/skills ~/.cursor/skills + Cursor .agents/skills ~/.cursor/skills Codex .agents/skills ~/.codex/skills - Gemini CLI .agent/skills ~/.gemini/skills - Antigravity .agent/skills ~/.gemini/antigravity/skills + Gemini CLI .agents/skills ~/.gemini/skills + Antigravity .agents/skills ~/.gemini/antigravity/skills Use %[1]s--agent%[1]s and %[1]s--scope%[1]s to control placement, or %[1]s--dir%[1]s for a custom directory. The default scope is %[1]sproject%[1]s, and the default agent is %[1]sgithub-copilot%[1]s (when running non-interactively). + At project scope, GitHub Copilot, Cursor, Codex, Gemini CLI, and + Antigravity all use the shared %[1]s.agents/skills%[1]s directory. If you + select multiple hosts that resolve to the same destination, each skill is + installed there only once. + The first argument can be a GitHub repository in %[1]sOWNER/REPO%[1]s format or a local directory path (e.g. %[1]s.%[1]s, %[1]s./my-skills%[1]s, %[1]s~/skills%[1]s). For local directories, skills are auto-discovered using the same @@ -287,26 +292,14 @@ func installRun(opts *installOptions) error { homeDir := installer.ResolveHomeDir() source = ghrepo.FullName(opts.repo) - type hostPlan struct { - host *registry.AgentHost - skills []discovery.Skill - } - var plans []hostPlan - for _, host := range selectedHosts { - installSkills, err := checkOverwrite(opts, selectedSkills, host, scope, gitRoot, homeDir, canPrompt) - if err != nil { - return err - } - if len(installSkills) == 0 { - fmt.Fprintf(opts.IO.ErrOut, "No skills to install for %s.\n", host.Name) - continue - } - plans = append(plans, hostPlan{host: host, skills: installSkills}) + plans, err := buildInstallPlans(opts, selectedSkills, selectedHosts, scope, gitRoot, homeDir, canPrompt) + if err != nil { + return err } for _, plan := range plans { if len(plans) > 1 { - fmt.Fprintf(opts.IO.ErrOut, "\nInstalling to %s...\n", plan.host.Name) + fmt.Fprintf(opts.IO.ErrOut, "\nInstalling to %s for %s...\n", friendlyDir(plan.dir), formatPlanHosts(plan.hosts)) } result, err := installer.Install(&installer.Options{ @@ -317,11 +310,7 @@ func installRun(opts *installOptions) error { SHA: resolved.SHA, PinnedRef: opts.Pin, Skills: plan.skills, - AgentHost: plan.host, - Scope: scope, - Dir: opts.Dir, - GitRoot: gitRoot, - HomeDir: homeDir, + Dir: plan.dir, Client: apiClient, OnProgress: installProgress(opts.IO, len(plan.skills)), }) @@ -425,36 +414,20 @@ func runLocalInstall(opts *installOptions) error { gitRoot := installer.ResolveGitRoot(opts.GitClient) homeDir := installer.ResolveHomeDir() - type hostPlan struct { - host *registry.AgentHost - skills []discovery.Skill - } - var plans []hostPlan - for _, host := range selectedHosts { - installSkills, err := checkOverwrite(opts, selectedSkills, host, scope, gitRoot, homeDir, canPrompt) - if err != nil { - return err - } - if len(installSkills) == 0 { - fmt.Fprintf(opts.IO.ErrOut, "No skills to install for %s.\n", host.Name) - continue - } - plans = append(plans, hostPlan{host: host, skills: installSkills}) + plans, err := buildInstallPlans(opts, selectedSkills, selectedHosts, scope, gitRoot, homeDir, canPrompt) + if err != nil { + return err } for _, plan := range plans { if len(plans) > 1 { - fmt.Fprintf(opts.IO.ErrOut, "\nInstalling to %s...\n", plan.host.Name) + fmt.Fprintf(opts.IO.ErrOut, "\nInstalling to %s for %s...\n", friendlyDir(plan.dir), formatPlanHosts(plan.hosts)) } result, err := installer.InstallLocal(&installer.LocalOptions{ SourceDir: absSource, Skills: plan.skills, - AgentHost: plan.host, - Scope: scope, - Dir: opts.Dir, - GitRoot: gitRoot, - HomeDir: homeDir, + Dir: plan.dir, }) if err != nil { return err @@ -586,6 +559,12 @@ type skillSelector struct { fetchDescriptions func() } +type installPlan struct { + dir string + hosts []*registry.AgentHost + skills []discovery.Skill +} + func selectSkillsWithSelector(opts *installOptions, skills []discovery.Skill, canPrompt bool, sel skillSelector) ([]discovery.Skill, error) { checkCollisions := func(ss []discovery.Skill) error { return collisionError(ss, sel.sourceHint) @@ -823,20 +802,63 @@ func resolveScope(opts *installOptions, canPrompt bool) (registry.Scope, error) return registry.ScopeUser, nil } +func buildInstallPlans(opts *installOptions, selectedSkills []discovery.Skill, selectedHosts []*registry.AgentHost, scope registry.Scope, gitRoot, homeDir string, canPrompt bool) ([]installPlan, error) { + byDir := make(map[string]*installPlan) + orderedDirs := make([]string, 0, len(selectedHosts)) + + for _, host := range selectedHosts { + targetDir, err := resolveInstallDir(opts, host, scope, gitRoot, homeDir) + if err != nil { + return nil, err + } + + plan, ok := byDir[targetDir] + if !ok { + plan = &installPlan{dir: targetDir} + byDir[targetDir] = plan + orderedDirs = append(orderedDirs, targetDir) + } + plan.hosts = append(plan.hosts, host) + } + + plans := make([]installPlan, 0, len(orderedDirs)) + for _, dir := range orderedDirs { + plan := byDir[dir] + installSkills, err := checkOverwrite(opts, selectedSkills, plan.dir, canPrompt) + if err != nil { + return nil, err + } + if len(installSkills) == 0 { + fmt.Fprintf(opts.IO.ErrOut, "No skills to install in %s for %s.\n", friendlyDir(plan.dir), formatPlanHosts(plan.hosts)) + continue + } + plan.skills = installSkills + plans = append(plans, *plan) + } + + return plans, nil +} + +func resolveInstallDir(opts *installOptions, host *registry.AgentHost, scope registry.Scope, gitRoot, homeDir string) (string, error) { + if opts.Dir != "" { + return opts.Dir, nil + } + return host.InstallDir(scope, gitRoot, homeDir) +} + +func formatPlanHosts(hosts []*registry.AgentHost) string { + names := make([]string, len(hosts)) + for i, host := range hosts { + names[i] = host.Name + } + return strings.Join(names, ", ") +} + func truncateDescription(s string, maxWidth int) string { return text.Truncate(maxWidth, text.RemoveExcessiveWhitespace(s)) } -func checkOverwrite(opts *installOptions, skills []discovery.Skill, host *registry.AgentHost, scope registry.Scope, gitRoot, homeDir string, canPrompt bool) ([]discovery.Skill, error) { - targetDir := opts.Dir - if targetDir == "" { - var err error - targetDir, err = host.InstallDir(scope, gitRoot, homeDir) - if err != nil { - return nil, err - } - } - +func checkOverwrite(opts *installOptions, skills []discovery.Skill, targetDir string, canPrompt bool) ([]discovery.Skill, error) { var existing, fresh []discovery.Skill for _, s := range skills { dir := filepath.Join(targetDir, filepath.FromSlash(s.InstallName())) diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index 84fb24b7a..c753ae51c 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -1332,6 +1332,47 @@ func TestInstallProgress(t *testing.T) { assert.NotNil(t, installProgress(ios, 2)) } +func TestInstallRun_DeduplicatesSharedProjectDirAcrossHosts(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + pm := &prompter.PrompterMock{ + MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) { + return []int{0, 2}, nil // GitHub Copilot + Cursor share .agents/skills + }, + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil // project scope + }, + } + + err := installRun(&installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Force: true, + }) + require.NoError(t, err) + assert.Equal(t, 1, strings.Count(stdout.String(), "Installed git-commit")) + assert.NotContains(t, stderr.String(), "Installing to") +} + func TestRunLocalInstall(t *testing.T) { tests := []struct { name string diff --git a/pkg/cmd/skills/publish/publish_test.go b/pkg/cmd/skills/publish/publish_test.go index e4c368b70..862dbc9f7 100644 --- a/pkg/cmd/skills/publish/publish_test.go +++ b/pkg/cmd/skills/publish/publish_test.go @@ -661,7 +661,7 @@ func TestPublishRun(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { t.Helper() - require.NoError(t, os.MkdirAll(filepath.Join(dir, ".github", "skills", "installed"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".agents", "skills", "installed"), 0o755)) runGitInDir(t, dir, "init", "--initial-branch=main") runGitInDir(t, dir, "config", "user.email", "monalisa@github.com") runGitInDir(t, dir, "config", "user.name", "Monalisa Octocat") @@ -686,12 +686,12 @@ func TestPublishRun(t *testing.T) { --- Body. `)) - require.NoError(t, os.MkdirAll(filepath.Join(dir, ".github", "skills", "installed"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".agents", "skills", "installed"), 0o755)) runGitInDir(t, dir, "init", "--initial-branch=main") runGitInDir(t, dir, "config", "user.email", "monalisa@github.com") runGitInDir(t, dir, "config", "user.name", "Monalisa Octocat") - require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(".github/skills\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(".agents/skills\n"), 0o644)) runGitInDir(t, dir, "add", ".gitignore") runGitInDir(t, dir, "commit", "-m", "init") }, diff --git a/pkg/cmd/skills/update/update.go b/pkg/cmd/skills/update/update.go index 1dfe76007..afb8377e3 100644 --- a/pkg/cmd/skills/update/update.go +++ b/pkg/cmd/skills/update/update.go @@ -415,9 +415,9 @@ func updateRun(opts *updateOptions) error { } // scanAllAgents walks every registered agent's skill directory (project + user scope) and -// collects installed skills. Skills are deduplicated by directory path. +// collects installed skills. Shared install roots are scanned only once. func scanAllAgents(gitRoot, homeDir string) []installedSkill { - seen := make(map[string]bool) + scannedDirs := make(map[string]bool) var all []installedSkill for i := range registry.Agents { @@ -427,17 +427,15 @@ func scanAllAgents(gitRoot, homeDir string) []installedSkill { if err != nil { continue } + if scannedDirs[dir] { + continue + } + scannedDirs[dir] = true skills, err := scanInstalledSkills(dir, host, scope) if err != nil { continue } - for _, s := range skills { - if seen[s.dir] { - continue - } - seen[s.dir] = true - all = append(all, s) - } + all = append(all, skills...) } } diff --git a/pkg/cmd/skills/update/update_test.go b/pkg/cmd/skills/update/update_test.go index 81fc87efe..0c7953f66 100644 --- a/pkg/cmd/skills/update/update_test.go +++ b/pkg/cmd/skills/update/update_test.go @@ -12,6 +12,7 @@ import ( "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/skills/registry" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" @@ -243,6 +244,48 @@ func TestPromptForSkillOrigin(t *testing.T) { } } +func TestScanAllAgentsDeduplicatesSharedProjectDirs(t *testing.T) { + repoDir := t.TempDir() + homeDir := t.TempDir() + + sharedSkillDir := filepath.Join(repoDir, ".agents", "skills", "git-commit") + require.NoError(t, os.MkdirAll(sharedSkillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(sharedSkillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: git-commit + metadata: + github-owner: monalisa + github-repo: octocat-skills + github-tree-sha: abc123 + --- + Body + `)), 0o644)) + + claudeSkillDir := filepath.Join(repoDir, ".claude", "skills", "code-review") + require.NoError(t, os.MkdirAll(claudeSkillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(claudeSkillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-owner: monalisa + github-repo: octocat-skills + github-tree-sha: def456 + --- + Body + `)), 0o644)) + + skills := scanAllAgents(repoDir, homeDir) + require.Len(t, skills, 2) + + byName := make(map[string]installedSkill) + for _, skill := range skills { + byName[skill.name] = skill + } + + assert.Equal(t, registry.ScopeProject, byName["git-commit"].scope) + assert.Equal(t, registry.ScopeProject, byName["code-review"].scope) +} + func TestUpdateRun(t *testing.T) { tests := []struct { name string @@ -260,7 +303,7 @@ func TestUpdateRun(t *testing.T) { t.Helper() t.Setenv("HOME", dir) t.Setenv("USERPROFILE", dir) - skillDir := filepath.Join(dir, ".github", "skills", "code-review") + skillDir := filepath.Join(dir, ".agents", "skills", "code-review") require.NoError(t, os.MkdirAll(skillDir, 0o755)) require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` --- From 663df07fcf3003e0be0293142701a7349e35ad52 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Wed, 8 Apr 2026 15:12:37 +0100 Subject: [PATCH 09/27] cleanup frontmatter fields remove git sha because we only need git tree sha remove github-owner from frontmatter, and make github-repo support full url. Only support github.com as host, error out otherwise --- internal/skills/frontmatter/frontmatter.go | 9 +- .../skills/frontmatter/frontmatter_test.go | 20 ++--- internal/skills/installer/installer.go | 2 +- internal/skills/installer/installer_test.go | 4 +- internal/skills/source/source.go | 66 ++++++++++++++ internal/skills/source/source_test.go | 76 ++++++++++++++++ pkg/cmd/skills/install/install.go | 33 ++++--- pkg/cmd/skills/install/install_test.go | 19 +++- pkg/cmd/skills/preview/preview.go | 4 + pkg/cmd/skills/preview/preview_test.go | 10 +++ pkg/cmd/skills/publish/publish.go | 62 +++++++++---- pkg/cmd/skills/publish/publish_test.go | 21 +++++ pkg/cmd/skills/search/search.go | 4 + pkg/cmd/skills/search/search_test.go | 19 ++++ pkg/cmd/skills/update/update.go | 60 ++++++++----- pkg/cmd/skills/update/update_test.go | 86 ++++++++++--------- 16 files changed, 383 insertions(+), 112 deletions(-) create mode 100644 internal/skills/source/source.go create mode 100644 internal/skills/source/source_test.go diff --git a/internal/skills/frontmatter/frontmatter.go b/internal/skills/frontmatter/frontmatter.go index 034068884..87ad067a0 100644 --- a/internal/skills/frontmatter/frontmatter.go +++ b/internal/skills/frontmatter/frontmatter.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/cli/cli/v2/internal/skills/source" "gopkg.in/yaml.v3" ) @@ -66,7 +67,7 @@ func Parse(content string) (*ParseResult, error) { // collisions with other tools' metadata. // pinnedRef is the user's explicit --pin value; empty string means unpinned. // skillPath is the skill's source path in the repo (e.g. "skills/author/my-skill"). -func InjectGitHubMetadata(content string, owner, repo, ref, sha, treeSHA, pinnedRef, skillPath string) (string, error) { +func InjectGitHubMetadata(content string, host, owner, repo, ref, treeSHA, pinnedRef, skillPath string) (string, error) { result, err := Parse(content) if err != nil { return "", err @@ -80,10 +81,10 @@ func InjectGitHubMetadata(content string, owner, repo, ref, sha, treeSHA, pinned if meta == nil { meta = make(map[string]interface{}) } - meta["github-owner"] = owner - meta["github-repo"] = repo + delete(meta, "github-owner") + meta["github-repo"] = source.BuildRepoURL(host, owner, repo) meta["github-ref"] = ref - meta["github-sha"] = sha + delete(meta, "github-sha") meta["github-tree-sha"] = treeSHA meta["github-path"] = skillPath if pinnedRef != "" { diff --git a/internal/skills/frontmatter/frontmatter_test.go b/internal/skills/frontmatter/frontmatter_test.go index 51fe09133..229eadd18 100644 --- a/internal/skills/frontmatter/frontmatter_test.go +++ b/internal/skills/frontmatter/frontmatter_test.go @@ -67,10 +67,10 @@ func TestInjectGitHubMetadata(t *testing.T) { tests := []struct { name string content string + host string owner string repo string ref string - sha string treeSHA string pinnedRef string skillPath string @@ -86,23 +86,23 @@ func TestInjectGitHubMetadata(t *testing.T) { --- # Body `), + host: "github.com", owner: "monalisa", repo: "octocat-skills", ref: "v1.0.0", - sha: "abc123", treeSHA: "tree456", pinnedRef: "", skillPath: "skills/my-skill", wantContains: []string{ - "github-owner: monalisa", - "github-repo: octocat-skills", + "github-repo: https://github.com/monalisa/octocat-skills", "github-ref: v1.0.0", - "github-sha: abc123", "github-tree-sha: tree456", "github-path: skills/my-skill", "# Body", }, wantNotContain: []string{ + "github-owner", + "github-sha", "github-pinned", }, }, @@ -114,10 +114,10 @@ func TestInjectGitHubMetadata(t *testing.T) { --- # Body `), + host: "github.com", owner: "monalisa", repo: "octocat-skills", ref: "v1.0.0", - sha: "abc", treeSHA: "tree", pinnedRef: "v1.0.0", skillPath: "skills/my-skill", @@ -128,24 +128,24 @@ func TestInjectGitHubMetadata(t *testing.T) { { name: "injects metadata into content with no frontmatter", content: "# Body only\n", + host: "github.com", owner: "monalisa", repo: "octocat-skills", ref: "v1.0.0", - sha: "abc123", treeSHA: "tree456", pinnedRef: "", skillPath: "skills/my-skill", wantContains: []string{ - "github-owner: monalisa", - "github-repo: octocat-skills", + "github-repo: https://github.com/monalisa/octocat-skills", "# Body only", }, + wantNotContain: []string{"github-owner", "github-sha"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := InjectGitHubMetadata(tt.content, tt.owner, tt.repo, tt.ref, tt.sha, tt.treeSHA, tt.pinnedRef, tt.skillPath) + got, err := InjectGitHubMetadata(tt.content, tt.host, tt.owner, tt.repo, tt.ref, tt.treeSHA, tt.pinnedRef, tt.skillPath) require.NoError(t, err) for _, s := range tt.wantContains { assert.Contains(t, got, s) diff --git a/internal/skills/installer/installer.go b/internal/skills/installer/installer.go index ce5370004..5fdf99ce0 100644 --- a/internal/skills/installer/installer.go +++ b/internal/skills/installer/installer.go @@ -282,7 +282,7 @@ func installSkill(opts *Options, skill discovery.Skill, baseDir string) error { } if filepath.Base(relPath) == "SKILL.md" { - content, err = frontmatter.InjectGitHubMetadata(content, opts.Owner, opts.Repo, opts.Ref, file.SHA, skill.TreeSHA, opts.PinnedRef, skill.Path) + content, err = frontmatter.InjectGitHubMetadata(content, opts.Host, opts.Owner, opts.Repo, opts.Ref, skill.TreeSHA, opts.PinnedRef, skill.Path) if err != nil { return fmt.Errorf("could not inject metadata: %w", err) } diff --git a/internal/skills/installer/installer_test.go b/internal/skills/installer/installer_test.go index ea9c619c2..85c8bcf18 100644 --- a/internal/skills/installer/installer_test.go +++ b/internal/skills/installer/installer_test.go @@ -248,8 +248,8 @@ func TestInstallSkill(t *testing.T) { t.Helper() content, err := os.ReadFile(filepath.Join(destDir, "pr-summary", "SKILL.md")) require.NoError(t, err) - assert.Contains(t, string(content), "github-owner: monalisa") - assert.Contains(t, string(content), "github-repo: octocat-skills") + assert.NotContains(t, string(content), "github-owner:") + assert.Contains(t, string(content), "github-repo: https://github.com/monalisa/octocat-skills") }, }, { diff --git a/internal/skills/source/source.go b/internal/skills/source/source.go new file mode 100644 index 000000000..5e8f52888 --- /dev/null +++ b/internal/skills/source/source.go @@ -0,0 +1,66 @@ +package source + +import ( + "fmt" + "strings" + + "github.com/cli/cli/v2/internal/ghrepo" +) + +const SupportedHost = "github.com" + +// BuildRepoURL returns the canonical repository URL stored in skill metadata. +func BuildRepoURL(host, owner, repo string) string { + return ghrepo.GenerateRepoURL(ghrepo.NewWithHost(owner, repo, host), "") +} + +// ParseRepoURL parses a repository URL stored in skill metadata. +func ParseRepoURL(raw string) (ghrepo.Interface, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("repository URL is empty") + } + + repo, err := ghrepo.FromFullName(raw) + if err != nil { + return nil, fmt.Errorf("invalid repository URL %q: %w", raw, err) + } + + return repo, nil +} + +// ParseMetadataRepo extracts repository information from skill metadata. +func ParseMetadataRepo(meta map[string]interface{}) (ghrepo.Interface, bool, error) { + if meta == nil { + return nil, false, nil + } + + repoValue, _ := meta["github-repo"].(string) + if repoValue == "" { + return nil, false, nil + } + + repo, err := ParseRepoURL(repoValue) + if err != nil { + return nil, true, err + } + + return repo, true, nil +} + +// ValidateSupportedHost rejects hosts that are not supported in public preview. +func ValidateSupportedHost(host string) error { + host = normalizeHost(host) + if host == "" { + return fmt.Errorf("could not determine repository host") + } + if host != SupportedHost { + return fmt.Errorf("GitHub Skills currently supports only %s as a host; got %s", SupportedHost, host) + } + return nil +} + +func normalizeHost(host string) string { + host = strings.TrimSpace(strings.ToLower(host)) + return strings.TrimPrefix(host, "www.") +} diff --git a/internal/skills/source/source_test.go b/internal/skills/source/source_test.go new file mode 100644 index 000000000..f797591b4 --- /dev/null +++ b/internal/skills/source/source_test.go @@ -0,0 +1,76 @@ +package source + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildRepoURL(t *testing.T) { + assert.Equal(t, "https://github.com/monalisa/octocat-skills", BuildRepoURL("github.com", "monalisa", "octocat-skills")) +} + +func TestParseMetadataRepo(t *testing.T) { + tests := []struct { + name string + meta map[string]interface{} + wantOwner string + wantRepo string + wantHost string + wantFound bool + wantErr string + }{ + { + name: "parses repo url metadata", + meta: map[string]interface{}{ + "github-repo": "https://github.com/monalisa/octocat-skills", + }, + wantOwner: "monalisa", + wantRepo: "octocat-skills", + wantHost: SupportedHost, + wantFound: true, + }, + { + name: "invalid repo url", + meta: map[string]interface{}{ + "github-repo": "not a url", + }, + wantFound: true, + wantErr: "invalid repository URL", + }, + { + name: "missing repo metadata", + meta: map[string]interface{}{}, + wantFound: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo, found, err := ParseMetadataRepo(tt.meta) + assert.Equal(t, tt.wantFound, found) + if !tt.wantFound { + require.NoError(t, err) + assert.Nil(t, repo) + return + } + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + require.NotNil(t, repo) + assert.Equal(t, tt.wantOwner, repo.RepoOwner()) + assert.Equal(t, tt.wantRepo, repo.RepoName()) + assert.Equal(t, tt.wantHost, repo.RepoHost()) + }) + } +} + +func TestValidateSupportedHost(t *testing.T) { + require.NoError(t, ValidateSupportedHost("github.com")) + require.ErrorContains(t, ValidateSupportedHost("acme.ghes.com"), "supports only github.com") +} diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index b0ca4bf4a..0cc5c6131 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -20,6 +20,7 @@ import ( "github.com/cli/cli/v2/internal/skills/frontmatter" "github.com/cli/cli/v2/internal/skills/installer" "github.com/cli/cli/v2/internal/skills/registry" + "github.com/cli/cli/v2/internal/skills/source" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -126,10 +127,11 @@ func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra. name or use the %[1]s--pin%[1]s flag. The version is resolved as a git tag or commit SHA. Installed skills have GitHub tracking metadata injected into their - frontmatter (%[1]sgithub-owner%[1]s, %[1]sgithub-repo%[1]s, %[1]sgithub-ref%[1]s, - %[1]sgithub-sha%[1]s, %[1]sgithub-tree-sha%[1]s, %[1]sgithub-path%[1]s). This + frontmatter (%[1]sgithub-repo%[1]s, %[1]sgithub-ref%[1]s, + %[1]sgithub-tree-sha%[1]s, %[1]sgithub-path%[1]s). This metadata identifies the source repository and enables %[1]sgh skills update%[1]s to detect changes — the tree SHA serves as an ETag for staleness checks. + The %[1]sgithub-repo%[1]s value is stored as a full repository URL. When run interactively, the command prompts for any missing arguments. When run non-interactively, %[1]srepository%[1]s is required, and either a @@ -226,12 +228,12 @@ func installRun(opts *installOptions) error { return runLocalInstall(opts) } - repo, source, err := resolveRepoArg(opts.SkillSource, canPrompt, opts.Prompter) + repo, repoSource, err := resolveRepoArg(opts.SkillSource, canPrompt, opts.Prompter) if err != nil { return err } opts.repo = repo - opts.SkillSource = source + opts.SkillSource = repoSource parseSkillFromOpts(opts) @@ -242,6 +244,9 @@ func installRun(opts *installOptions) error { apiClient := api.NewClientFromHTTP(httpClient) hostname := opts.repo.RepoHost() + if err := source.ValidateSupportedHost(hostname); err != nil { + return err + } resolved, err := resolveVersion(opts, apiClient, hostname) if err != nil { @@ -290,7 +295,7 @@ func installRun(opts *installOptions) error { gitRoot := installer.ResolveGitRoot(opts.GitClient) homeDir := installer.ResolveHomeDir() - source = ghrepo.FullName(opts.repo) + repoSource = ghrepo.FullName(opts.repo) plans, err := buildInstallPlans(opts, selectedSkills, selectedHosts, scope, gitRoot, homeDir, canPrompt) if err != nil { @@ -322,11 +327,11 @@ func installRun(opts *installOptions) error { for _, name := range result.Installed { fmt.Fprintf(opts.IO.Out, "%s Installed %s (from %s@%s) in %s\n", - cs.SuccessIcon(), name, source, resolved.Ref, friendlyDir(result.Dir)) + cs.SuccessIcon(), name, repoSource, resolved.Ref, friendlyDir(result.Dir)) } printFileTree(opts.IO.Out, cs, result.Dir, result.Installed) - printReviewHint(opts.IO.ErrOut, cs, source, result.Installed) + printReviewHint(opts.IO.ErrOut, cs, repoSource, result.Installed) } if err != nil { @@ -914,16 +919,18 @@ func existingSkillPrompt(targetDir string, incoming discovery.Skill) string { return fmt.Sprintf("Skill %q already exists. Overwrite?", incoming.DisplayName()) } - owner, _ := result.Metadata.Meta["github-owner"].(string) - repo, _ := result.Metadata.Meta["github-repo"].(string) + repoInfo, _, err := source.ParseMetadataRepo(result.Metadata.Meta) ref, _ := result.Metadata.Meta["github-ref"].(string) + if err != nil { + return fmt.Sprintf("Skill %q already exists. Overwrite?", incoming.DisplayName()) + } - if owner != "" && repo != "" { - source := owner + "/" + repo + if repoInfo != nil { + sourceName := ghrepo.FullName(repoInfo) if ref != "" { - source += "@" + ref + sourceName += "@" + ref } - return fmt.Sprintf("Skill %q already installed from %s. Overwrite?", incoming.DisplayName(), source) + return fmt.Sprintf("Skill %q already installed from %s. Overwrite?", incoming.DisplayName(), sourceName) } return fmt.Sprintf("Skill %q already exists. Overwrite?", incoming.DisplayName()) diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index c753ae51c..060b146a4 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -1049,8 +1049,7 @@ func TestInstallRun(t *testing.T) { name: git-commit description: Writes commits metadata: - github-owner: someowner - github-repo: somerepo + github-repo: https://github.com/someowner/somerepo github-ref: v0.5.0 --- # Git Commit @@ -1077,6 +1076,22 @@ func TestInstallRun(t *testing.T) { }, wantStdout: "Installed git-commit", }, + { + name: "unsupported host returns error", + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "acme.ghes.com/monalisa/octocat-skills", + SkillName: "git-commit", + } + }, + wantErr: "supports only github.com", + }, { name: "select all skills in interactive prompt", isTTY: true, diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index ce7c49ef2..55ba125e3 100644 --- a/pkg/cmd/skills/preview/preview.go +++ b/pkg/cmd/skills/preview/preview.go @@ -14,6 +14,7 @@ import ( "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/skills/discovery" "github.com/cli/cli/v2/internal/skills/frontmatter" + "github.com/cli/cli/v2/internal/skills/source" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/markdown" @@ -99,6 +100,9 @@ func previewRun(opts *previewOptions) error { owner := repo.RepoOwner() repoName := repo.RepoName() hostname := repo.RepoHost() + if err := source.ValidateSupportedHost(hostname); err != nil { + return err + } httpClient, err := opts.HttpClient() if err != nil { diff --git a/pkg/cmd/skills/preview/preview_test.go b/pkg/cmd/skills/preview/preview_test.go index 0cbea6ae7..cd623fa06 100644 --- a/pkg/cmd/skills/preview/preview_test.go +++ b/pkg/cmd/skills/preview/preview_test.go @@ -283,6 +283,16 @@ func TestPreviewRun(t *testing.T) { } } +func TestPreviewRun_UnsupportedHost(t *testing.T) { + ios, _, _, _ := iostreams.Test() + err := previewRun(&previewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{}, nil }, + repo: ghrepo.NewWithHost("github", "awesome-copilot", "acme.ghes.com"), + }) + require.ErrorContains(t, err, "supports only github.com") +} + func TestPreviewRun_Interactive(t *testing.T) { skillContent := "# Selected Skill\n\nContent here." encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent)) diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index 200e3e2dd..96683bece 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -16,12 +16,12 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/gh" - "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/skills/discovery" "github.com/cli/cli/v2/internal/skills/frontmatter" "github.com/cli/cli/v2/internal/skills/registry" + "github.com/cli/cli/v2/internal/skills/source" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -342,10 +342,27 @@ func publishRun(opts *publishOptions) error { diagnostics = append(diagnostics, installedDirDiags...) // Remote repository checks (best-effort) - owner, repo := detectGitHubRemote(opts.GitClient) + repoInfo, remoteErr := detectGitHubRemote(opts.GitClient) + if remoteErr != nil { + return remoteErr + } + owner, repo := "", "" + if repoInfo != nil { + owner = repoInfo.RepoOwner() + repo = repoInfo.RepoName() + } hasTopic := false var existingTags []tagEntry if owner != "" && repo != "" { + if host == "" && repoInfo != nil { + host = repoInfo.RepoHost() + } + if host != "" { + if err := source.ValidateSupportedHost(host); err != nil { + return err + } + } + // Create API client for remote checks if not already injected if client == nil { httpClient, httpErr := opts.HttpClient() @@ -354,6 +371,9 @@ func publishRun(opts *publishOptions) error { cfg, cfgErr := opts.Config() if cfgErr == nil { host, _ = cfg.Authentication().DefaultHost() + if err := source.ValidateSupportedHost(host); err != nil { + return err + } client = apiClient } } @@ -844,53 +864,59 @@ func suggestNextTag(latest string) string { } // detectGitHubRemote attempts to detect the GitHub owner/repo from git remotes. -func detectGitHubRemote(gitClient *git.Client) (owner, repo string) { +func detectGitHubRemote(gitClient *git.Client) (ghrepo.Interface, error) { if gitClient == nil { - return "", "" + return nil, nil } // Try origin first if url, err := gitClient.RemoteURL(context.Background(), "origin"); err == nil { - if o, r := parseGitHubURL(url); o != "" { - return o, r + repo, parseErr := parseGitHubURL(url) + if parseErr != nil { + return nil, parseErr + } + if repo != nil { + return repo, nil } } // Fall back to any remote that points to GitHub remotes, err := gitClient.Remotes(context.Background()) if err != nil { - return "", "" + return nil, nil } for _, r := range remotes { if r.Name == "origin" { continue } if url, err := gitClient.RemoteURL(context.Background(), r.Name); err == nil { - if o, rp := parseGitHubURL(url); o != "" { - return o, rp + repo, parseErr := parseGitHubURL(url) + if parseErr != nil { + return nil, parseErr + } + if repo != nil { + return repo, nil } } } - return "", "" + return nil, nil } // parseGitHubURL extracts owner/repo from a GitHub remote URL. // Only GitHub.com URLs are recognized. -func parseGitHubURL(rawURL string) (owner, repo string) { +func parseGitHubURL(rawURL string) (ghrepo.Interface, error) { u, err := git.ParseURL(rawURL) if err != nil { - return "", "" + return nil, nil } r, err := ghrepo.FromURL(u) if err != nil { - return "", "" + return nil, nil } - // Only match github.com — the default GitHub host. - host := strings.ToLower(r.RepoHost()) - if host != ghinstance.Default() { - return "", "" + if err := source.ValidateSupportedHost(r.RepoHost()); err != nil { + return nil, nil } - return r.RepoOwner(), r.RepoName() + return r, nil } // detectMissingRepoDiagnostic explains why remote checks were skipped. diff --git a/pkg/cmd/skills/publish/publish_test.go b/pkg/cmd/skills/publish/publish_test.go index 862dbc9f7..d54d04ad3 100644 --- a/pkg/cmd/skills/publish/publish_test.go +++ b/pkg/cmd/skills/publish/publish_test.go @@ -140,6 +140,27 @@ func TestNewCmdPublish(t *testing.T) { } } +func TestPublishRun_UnsupportedHost(t *testing.T) { + dir := t.TempDir() + writeSkill(t, dir, "test-skill", heredoc.Doc(` + --- + name: test-skill + description: A test skill + --- + Body. + `)) + + ios, _, _, _ := iostreams.Test() + err := publishRun(&publishOptions{ + IO: ios, + Dir: dir, + GitClient: newTestGitClient(t, map[string]string{"origin": "https://github.com/monalisa/skills-repo.git"}), + client: api.NewClientFromHTTP(&http.Client{}), + host: "acme.ghes.com", + }) + require.ErrorContains(t, err, "supports only github.com") +} + func TestPublishRun(t *testing.T) { tests := []struct { name string diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go index 6a0cc95d0..03874c30c 100644 --- a/pkg/cmd/skills/search/search.go +++ b/pkg/cmd/skills/search/search.go @@ -19,6 +19,7 @@ import ( "github.com/cli/cli/v2/internal/skills/discovery" "github.com/cli/cli/v2/internal/skills/frontmatter" "github.com/cli/cli/v2/internal/skills/registry" + "github.com/cli/cli/v2/internal/skills/source" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmdutil" @@ -200,6 +201,9 @@ func searchRun(opts *searchOptions) error { return err } host, _ := cfg.Authentication().DefaultHost() + if err := source.ValidateSupportedHost(host); err != nil { + return err + } opts.IO.StartProgressIndicatorWithLabel("Searching for skills") diff --git a/pkg/cmd/skills/search/search_test.go b/pkg/cmd/skills/search/search_test.go index e3b8b26d6..6e35465cd 100644 --- a/pkg/cmd/skills/search/search_test.go +++ b/pkg/cmd/skills/search/search_test.go @@ -15,6 +15,25 @@ import ( "github.com/stretchr/testify/require" ) +func TestSearchRun_UnsupportedHost(t *testing.T) { + ios, _, _, _ := iostreams.Test() + cfg := config.NewBlankConfig() + authCfg := cfg.Authentication() + authCfg.SetDefaultHost("acme.ghes.com", "user") + cfg.AuthenticationFunc = func() gh.AuthConfig { + return authCfg + } + err := searchRun(&searchOptions{ + IO: ios, + Query: "terraform", + Page: 1, + Limit: defaultLimit, + HttpClient: func() (*http.Client, error) { return &http.Client{}, nil }, + Config: func() (gh.Config, error) { return cfg, nil }, + }) + require.ErrorContains(t, err, "supports only github.com") +} + func TestNewCmdSearch(t *testing.T) { tests := []struct { name string diff --git a/pkg/cmd/skills/update/update.go b/pkg/cmd/skills/update/update.go index afb8377e3..0a9d1b1fa 100644 --- a/pkg/cmd/skills/update/update.go +++ b/pkg/cmd/skills/update/update.go @@ -17,6 +17,7 @@ import ( "github.com/cli/cli/v2/internal/skills/frontmatter" "github.com/cli/cli/v2/internal/skills/installer" "github.com/cli/cli/v2/internal/skills/registry" + "github.com/cli/cli/v2/internal/skills/source" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -43,15 +44,17 @@ type updateOptions struct { // installedSkill represents a locally installed skill parsed from its SKILL.md frontmatter. type installedSkill struct { - name string - owner string - repo string - treeSHA string // tree SHA at install time - pinned string // explicit pin value (empty = unpinned) - sourcePath string // original path in source repo (e.g. "skills/author/name") - dir string // local directory path - host *registry.AgentHost - scope registry.Scope + name string + repoHost string + owner string + repo string + treeSHA string // tree SHA at install time + pinned string // explicit pin value (empty = unpinned) + sourcePath string // original path in source repo (e.g. "skills/author/name") + dir string // local directory path + host *registry.AgentHost + scope registry.Scope + metadataErr error } // pendingUpdate describes a single skill that has an available update. @@ -149,12 +152,6 @@ func updateRun(opts *updateOptions) error { } apiClient := api.NewClientFromHTTP(httpClient) - cfg, err := opts.Config() - if err != nil { - return err - } - hostname, _ := cfg.Authentication().DefaultHost() - gitRoot := installer.ResolveGitRoot(opts.GitClient) homeDir := installer.ResolveHomeDir() @@ -193,6 +190,12 @@ func updateRun(opts *updateOptions) error { installed = filtered } + for _, s := range installed { + if s.metadataErr != nil { + return fmt.Errorf("skill %s has invalid repository metadata: %w", s.name, s.metadataErr) + } + } + // Prompt for metadata on skills missing it (before starting progress indicator) var noMeta []string // Track skills where the user provided a source repo interactively. @@ -226,6 +229,7 @@ func updateRun(opts *updateOptions) error { } s.owner = owner s.repo = repo + s.repoHost = source.SupportedHost prompted[s.dir] = promptedEntry{name: s.name, source: owner + "/" + repo} } @@ -234,7 +238,7 @@ func updateRun(opts *updateOptions) error { var updates []pendingUpdate var pinned []installedSkill - type repoKey struct{ owner, repo string } + type repoKey struct{ host, owner, repo string } repoSkills := make(map[repoKey][]discovery.Skill) repoRefs := make(map[repoKey]*discovery.ResolvedRef) repoErrors := make(map[repoKey]bool) @@ -248,7 +252,7 @@ func updateRun(opts *updateOptions) error { continue } - key := repoKey{s.owner, s.repo} + key := repoKey{s.repoHost, s.owner, s.repo} if repoErrors[key] { continue @@ -256,7 +260,7 @@ func updateRun(opts *updateOptions) error { // Resolve ref and discover skills once per repo if _, ok := repoRefs[key]; !ok { - resolved, resolveErr := discovery.ResolveRef(apiClient, hostname, s.owner, s.repo, "") + resolved, resolveErr := discovery.ResolveRef(apiClient, s.repoHost, s.owner, s.repo, "") if resolveErr != nil { repoErrors[key] = true opts.IO.StopProgressIndicator() @@ -266,7 +270,7 @@ func updateRun(opts *updateOptions) error { } repoRefs[key] = resolved - skills, discoverErr := discovery.DiscoverSkills(apiClient, hostname, s.owner, s.repo, resolved.SHA) + skills, discoverErr := discovery.DiscoverSkills(apiClient, s.repoHost, s.owner, s.repo, resolved.SHA) if discoverErr != nil { repoErrors[key] = true opts.IO.StopProgressIndicator() @@ -302,7 +306,7 @@ func updateRun(opts *updateOptions) error { // Warn about prompted skills that weren't found in the remote repo for _, entry := range prompted { parts := strings.SplitN(entry.source, "/", 2) - key := repoKey{parts[0], parts[1]} + key := repoKey{source.SupportedHost, parts[0], parts[1]} skills, resolved := repoSkills[key] if !resolved { continue @@ -371,7 +375,7 @@ func updateRun(opts *updateOptions) error { var failed bool for _, u := range updates { installOpts := &installer.Options{ - Host: hostname, + Host: u.local.repoHost, Owner: u.local.owner, Repo: u.local.repo, Ref: u.resolved.Ref, @@ -507,8 +511,18 @@ func parseInstalledSkill(data []byte, name, dir string, host *registry.AgentHost } if result.Metadata.Meta != nil { - s.owner, _ = result.Metadata.Meta["github-owner"].(string) - s.repo, _ = result.Metadata.Meta["github-repo"].(string) + repoInfo, ok, repoErr := source.ParseMetadataRepo(result.Metadata.Meta) + if repoErr != nil { + s.metadataErr = repoErr + } else if ok { + if err := source.ValidateSupportedHost(repoInfo.RepoHost()); err != nil { + s.metadataErr = err + } else { + s.repoHost = repoInfo.RepoHost() + s.owner = repoInfo.RepoOwner() + s.repo = repoInfo.RepoName() + } + } s.treeSHA, _ = result.Metadata.Meta["github-tree-sha"].(string) s.pinned, _ = result.Metadata.Meta["github-pinned"].(string) s.sourcePath, _ = result.Metadata.Meta["github-path"].(string) diff --git a/pkg/cmd/skills/update/update_test.go b/pkg/cmd/skills/update/update_test.go index 0c7953f66..b9aabe86a 100644 --- a/pkg/cmd/skills/update/update_test.go +++ b/pkg/cmd/skills/update/update_test.go @@ -91,8 +91,7 @@ func TestScanInstalledSkills(t *testing.T) { name: git-commit description: Git commit helper metadata: - github-owner: monalisa - github-repo: awesome-copilot + github-repo: https://github.com/monalisa/awesome-copilot github-tree-sha: abc123 github-path: skills/git-commit --- @@ -117,8 +116,7 @@ func TestScanInstalledSkills(t *testing.T) { --- name: pinned-skill metadata: - github-owner: octocat - github-repo: hubot-skills + github-repo: https://github.com/octocat/hubot-skills github-tree-sha: def456 github-pinned: v1.0.0 --- @@ -138,6 +136,7 @@ func TestScanInstalledSkills(t *testing.T) { gc := byName["git-commit"] assert.Equal(t, "monalisa", gc.owner) assert.Equal(t, "awesome-copilot", gc.repo) + assert.Equal(t, "github.com", gc.repoHost) assert.Equal(t, "abc123", gc.treeSHA) assert.Equal(t, "skills/git-commit", gc.sourcePath) assert.Empty(t, gc.pinned) @@ -147,9 +146,34 @@ func TestScanInstalledSkills(t *testing.T) { assert.Empty(t, us.repo) ps := byName["pinned-skill"] + assert.Equal(t, "github.com", ps.repoHost) assert.Equal(t, "v1.0.0", ps.pinned) }, }, + { + name: "unsupported host metadata returns error", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "enterprise-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: enterprise-skill + metadata: + github-repo: https://acme.ghes.com/monalisa/octocat-skills + github-tree-sha: abc123 + --- + body + `)), 0o644)) + }, + verify: func(t *testing.T, skills []installedSkill, err error) { + t.Helper() + require.NoError(t, err) + require.Len(t, skills, 1) + require.Error(t, skills[0].metadataErr) + assert.Contains(t, skills[0].metadataErr.Error(), "supports only github.com") + }, + }, { name: "non-existent directory returns nil", // no setup — dir does not exist @@ -254,8 +278,7 @@ func TestScanAllAgentsDeduplicatesSharedProjectDirs(t *testing.T) { --- name: git-commit metadata: - github-owner: monalisa - github-repo: octocat-skills + github-repo: https://github.com/monalisa/octocat-skills github-tree-sha: abc123 --- Body @@ -267,8 +290,7 @@ func TestScanAllAgentsDeduplicatesSharedProjectDirs(t *testing.T) { --- name: code-review metadata: - github-owner: monalisa - github-repo: octocat-skills + github-repo: https://github.com/monalisa/octocat-skills github-tree-sha: def456 --- Body @@ -309,8 +331,7 @@ func TestUpdateRun(t *testing.T) { --- name: code-review metadata: - github-owner: monalisa - github-repo: octocat-skills + github-repo: https://github.com/monalisa/octocat-skills github-tree-sha: currentsha github-path: skills/code-review --- @@ -368,8 +389,7 @@ func TestUpdateRun(t *testing.T) { --- name: octocat-skill metadata: - github-owner: octocat - github-repo: hubot-skills + github-repo: https://github.com/octocat/hubot-skills github-tree-sha: abc --- `)), 0o644)) @@ -400,8 +420,7 @@ func TestUpdateRun(t *testing.T) { --- name: pinned-skill metadata: - github-owner: octocat - github-repo: hubot-skills + github-repo: https://github.com/octocat/hubot-skills github-tree-sha: abc123 github-pinned: v1.0.0 --- @@ -463,8 +482,7 @@ func TestUpdateRun(t *testing.T) { --- name: monalisa-skill metadata: - github-owner: monalisa - github-repo: octocat-skills + github-repo: https://github.com/monalisa/octocat-skills github-tree-sha: abc123def456 github-path: skills/monalisa-skill --- @@ -508,8 +526,7 @@ func TestUpdateRun(t *testing.T) { --- name: hubot-skill metadata: - github-owner: hubot - github-repo: octocat-skills + github-repo: https://github.com/hubot/octocat-skills github-tree-sha: oldsha123 github-path: skills/hubot-skill --- @@ -557,8 +574,7 @@ func TestUpdateRun(t *testing.T) { --- name: hubot-skill metadata: - github-owner: hubot - github-repo: octocat-skills + github-repo: https://github.com/hubot/octocat-skills github-tree-sha: oldsha123 github-path: skills/hubot-skill --- @@ -606,8 +622,7 @@ func TestUpdateRun(t *testing.T) { --- name: code-review metadata: - github-owner: monalisa - github-repo: octocat-skills + github-repo: https://github.com/monalisa/octocat-skills github-tree-sha: oldsha000 github-path: skills/code-review --- @@ -650,7 +665,7 @@ func TestUpdateRun(t *testing.T) { t.Helper() content, err := os.ReadFile(filepath.Join(dir, "code-review", "SKILL.md")) require.NoError(t, err) - assert.Contains(t, string(content), "github-owner: monalisa") + assert.Contains(t, string(content), "github-repo: https://github.com/monalisa/octocat-skills") assert.NotContains(t, string(content), "Old content") }, wantStdout: "Updated code-review", @@ -668,8 +683,7 @@ func TestUpdateRun(t *testing.T) { --- name: code-review metadata: - github-owner: monalisa - github-repo: octocat-skills + github-repo: https://github.com/monalisa/octocat-skills github-tree-sha: oldsha000 github-path: skills/monalisa/code-review --- @@ -712,7 +726,7 @@ func TestUpdateRun(t *testing.T) { t.Helper() content, err := os.ReadFile(filepath.Join(dir, "monalisa", "code-review", "SKILL.md")) require.NoError(t, err) - assert.Contains(t, string(content), "github-owner: monalisa") + assert.Contains(t, string(content), "github-repo: https://github.com/monalisa/octocat-skills") assert.NotContains(t, string(content), "Old namespaced content") }, wantStdout: "Updated monalisa/code-review", @@ -730,8 +744,7 @@ func TestUpdateRun(t *testing.T) { --- name: code-review metadata: - github-owner: monalisa - github-repo: octocat-skills + github-repo: https://github.com/monalisa/octocat-skills github-tree-sha: oldsha000 github-path: skills/code-review --- @@ -790,8 +803,7 @@ func TestUpdateRun(t *testing.T) { --- name: code-review metadata: - github-owner: monalisa - github-repo: octocat-skills + github-repo: https://github.com/monalisa/octocat-skills github-tree-sha: oldsha000 github-path: skills/code-review --- @@ -853,8 +865,7 @@ func TestUpdateRun(t *testing.T) { --- name: code-review metadata: - github-owner: monalisa - github-repo: octocat-skills + github-repo: https://github.com/monalisa/octocat-skills github-tree-sha: oldsha000 github-path: skills/code-review --- @@ -990,7 +1001,7 @@ func TestUpdateRun(t *testing.T) { content, err := os.ReadFile(filepath.Join(dir, "manual-skill", "SKILL.md")) require.NoError(t, err) assert.NotContains(t, string(content), "Old manual content") - assert.Contains(t, string(content), "github-owner: monalisa") + assert.Contains(t, string(content), "github-repo: https://github.com/monalisa/octocat-skills") }, wantStdout: "Updated manual-skill", }, @@ -1007,8 +1018,7 @@ func TestUpdateRun(t *testing.T) { --- name: pinned-skill metadata: - github-owner: octocat - github-repo: hubot-skills + github-repo: https://github.com/octocat/hubot-skills github-tree-sha: oldsha000 github-pinned: v1.0.0 github-path: skills/pinned-skill @@ -1067,8 +1077,7 @@ func TestUpdateRun(t *testing.T) { --- name: pinned-skill metadata: - github-owner: octocat - github-repo: hubot-skills + github-repo: https://github.com/octocat/hubot-skills github-tree-sha: abc123 github-pinned: v1.0.0 --- @@ -1102,8 +1111,7 @@ func TestUpdateRun(t *testing.T) { --- name: pinned-skill metadata: - github-owner: octocat - github-repo: hubot-skills + github-repo: https://github.com/octocat/hubot-skills github-tree-sha: oldsha000 github-pinned: v1.0.0 github-path: skills/pinned-skill From 1f5a6b8396ad574e40c31c8adbb358384be2e2ca Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Wed, 8 Apr 2026 16:38:38 +0100 Subject: [PATCH 10/27] clean up interface and fix a few bugs support specifying a sha in gh skills preview command --- internal/skills/discovery/discovery.go | 159 +++++++++++----- internal/skills/discovery/discovery_test.go | 170 ++++++++++++++++-- .../skills/frontmatter/frontmatter_test.go | 9 +- pkg/cmd/skills/install/install.go | 60 +++---- pkg/cmd/skills/install/install_test.go | 167 +++++++++++------ pkg/cmd/skills/preview/preview.go | 22 ++- pkg/cmd/skills/preview/preview_test.go | 65 +++++++ pkg/cmd/skills/publish/publish.go | 10 +- pkg/cmd/skills/search/search.go | 8 +- pkg/cmd/skills/skills.go | 5 +- pkg/cmd/skills/update/update.go | 16 +- 11 files changed, 522 insertions(+), 169 deletions(-) diff --git a/internal/skills/discovery/discovery.go b/internal/skills/discovery/discovery.go index 05a531bc9..4e54fd5e3 100644 --- a/internal/skills/discovery/discovery.go +++ b/internal/skills/discovery/discovery.go @@ -2,8 +2,10 @@ package discovery import ( "encoding/base64" + "errors" "fmt" "io" + "net/http" "os" "path" "path/filepath" @@ -68,10 +70,27 @@ func (s Skill) InstallName() string { // ResolvedRef contains the resolved git reference and its SHA. type ResolvedRef struct { - Ref string // tag name, branch name, or SHA + 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"` @@ -117,35 +136,41 @@ func ResolveRef(client *api.Client, host, owner, repo, version string) (*Resolve 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 --pin value. It tries, in order: -// tag → commit SHA. Branches are deliberately excluded because they are mutable -// and pinning to one gives a false sense of reproducibility. +// 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) { - tagPath := fmt.Sprintf("repos/%s/%s/git/ref/tags/%s", owner, repo, ref) - var refResp struct { - Object struct { - SHA string `json:"sha"` - Type string `json:"type"` - } `json:"object"` + // 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 err := client.REST(host, "GET", tagPath, nil, &refResp); err == nil { - sha := refResp.Object.SHA - if refResp.Object.Type == "tag" { - derefPath := fmt.Sprintf("repos/%s/%s/git/tags/%s", owner, repo, 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", ref, err) - } - sha = tagResp.Object.SHA - } - return &ResolvedRef{Ref: ref, SHA: sha}, nil + 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. + if resolved, err := resolveBranchRef(client, host, owner, repo, ref); err == nil { + return resolved, nil + } + if resolved, err := resolveTagRef(client, host, owner, repo, ref); err == nil { + return resolved, nil } commitPath := fmt.Sprintf("repos/%s/%s/commits/%s", owner, repo, ref) @@ -156,19 +181,80 @@ func resolveExplicitRef(client *api.Client, host, owner, repo, ref string) (*Res return &ResolvedRef{Ref: commitResp.SHA, SHA: commitResp.SHA}, nil } - return nil, fmt.Errorf("ref %q not found as tag or commit in %s/%s", ref, owner, repo) + 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", owner, repo, 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", owner, repo, 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", owner, repo, 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 +} + +// 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", owner, repo) var release releaseResponse if err := client.REST(host, "GET", apiPath, nil, &release); err != nil { - return nil, fmt.Errorf("no releases found: %w", err) + // 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. + var httpErr api.HTTPError + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + 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 release.TagName == "" { - return nil, fmt.Errorf("latest release has no tag") + return nil, &noReleasesError{reason: "latest release has no tag"} } - return resolveExplicitRef(client, host, owner, repo, release.TagName) + return resolveTagRef(client, host, owner, repo, release.TagName) } func resolveDefaultBranch(client *api.Client, host, owner, repo string) (*ResolvedRef, error) { @@ -181,18 +267,7 @@ func resolveDefaultBranch(client *api.Client, host, owner, repo string) (*Resolv if branch == "" { branch = "main" } - - refPath := fmt.Sprintf("repos/%s/%s/git/ref/heads/%s", owner, repo, 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("could not resolve branch %q: %w", branch, err) - } - - return &ResolvedRef{Ref: branch, SHA: refResp.Object.SHA}, nil + return resolveBranchRef(client, host, owner, repo, branch) } // skillMatch represents a matched SKILL.md file and its convention. @@ -267,7 +342,7 @@ func DiscoverSkills(client *api.Client, host, owner, repo, commitSHA string) ([] if tree.Truncated { return nil, fmt.Errorf( "repository tree for %s/%s is too large for full discovery\n"+ - " Use path-based install instead: gh skills install %s/%s skills/", + " Use path-based install instead: gh skill install %s/%s skills/", owner, repo, owner, repo, ) } diff --git a/internal/skills/discovery/discovery_test.go b/internal/skills/discovery/discovery_test.go index 974052530..3bc719ae8 100644 --- a/internal/skills/discovery/discovery_test.go +++ b/internal/skills/discovery/discovery_test.go @@ -162,6 +162,45 @@ func TestIsSpecCompliant(t *testing.T) { } } +func TestIsFullyQualifiedRef(t *testing.T) { + tests := []struct { + name string + ref string + want bool + }{ + {name: "branch ref", ref: "refs/heads/main", want: true}, + {name: "tag ref", ref: "refs/tags/v1.0", want: true}, + {name: "short branch name", ref: "main", want: false}, + {name: "short tag name", ref: "v1.0", want: false}, + {name: "bare SHA", ref: "abc123def456", want: false}, + {name: "empty", ref: "", want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, IsFullyQualifiedRef(tt.ref)) + }) + } +} + +func TestShortRef(t *testing.T) { + tests := []struct { + name string + ref string + want string + }{ + {name: "branch ref", ref: "refs/heads/main", want: "main"}, + {name: "tag ref", ref: "refs/tags/v1.0", want: "v1.0"}, + {name: "short name passthrough", ref: "main", want: "main"}, + {name: "bare SHA passthrough", ref: "abc123", want: "abc123"}, + {name: "empty passthrough", ref: "", want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, ShortRef(tt.ref)) + }) + } +} + func TestResolveRef(t *testing.T) { tests := []struct { name string @@ -172,22 +211,41 @@ func TestResolveRef(t *testing.T) { wantErr string }{ { - name: "explicit version resolves lightweight tag", + name: "short name resolves as branch first", + version: "main", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/main"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "branch-sha"}, + })) + }, + wantRef: "refs/heads/main", + wantSHA: "branch-sha", + }, + { + name: "short name falls back to tag when branch not found", version: "v1.0", stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/v1.0"), + httpmock.StatusStringResponse(404, "not found")) reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v1.0"), httpmock.JSONResponse(map[string]interface{}{ "object": map[string]interface{}{"sha": "abc123", "type": "commit"}, })) }, - wantRef: "v1.0", + wantRef: "refs/tags/v1.0", wantSHA: "abc123", }, { - name: "explicit version resolves annotated tag", + name: "short name resolves annotated tag", version: "v2.0", stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/v2.0"), + httpmock.StatusStringResponse(404, "not found")) reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v2.0"), httpmock.JSONResponse(map[string]interface{}{ @@ -199,13 +257,16 @@ func TestResolveRef(t *testing.T) { "object": map[string]interface{}{"sha": "real-commit-sha"}, })) }, - wantRef: "v2.0", + wantRef: "refs/tags/v2.0", wantSHA: "real-commit-sha", }, { - name: "explicit version falls back to commit SHA", + name: "short name falls back to commit SHA", version: "deadbeef", stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/deadbeef"), + httpmock.StatusStringResponse(404, "not found")) reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/deadbeef"), httpmock.StatusStringResponse(404, "not found")) @@ -217,9 +278,12 @@ func TestResolveRef(t *testing.T) { wantSHA: "deadbeef", }, { - name: "explicit version not found anywhere", + name: "short name not found anywhere", version: "nonexistent", stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/nonexistent"), + httpmock.StatusStringResponse(404, "not found")) reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/nonexistent"), httpmock.StatusStringResponse(404, "not found")) @@ -227,10 +291,70 @@ func TestResolveRef(t *testing.T) { httpmock.REST("GET", "repos/monalisa/octocat-skills/commits/nonexistent"), httpmock.StatusStringResponse(404, "not found")) }, - wantErr: `ref "nonexistent" not found as tag or commit in monalisa/octocat-skills`, + wantErr: `ref "nonexistent" not found as branch, tag, or commit in monalisa/octocat-skills`, }, { - name: "no version uses latest release", + name: "branch wins over tag with same short name", + version: "release", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/release"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "branch-sha"}, + })) + // tag stub is not registered because branch succeeds first + }, + wantRef: "refs/heads/release", + wantSHA: "branch-sha", + }, + { + name: "fully qualified tag ref resolved directly", + version: "refs/tags/v1.0", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v1.0"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "tag-sha", "type": "commit"}, + })) + }, + wantRef: "refs/tags/v1.0", + wantSHA: "tag-sha", + }, + { + name: "fully qualified branch ref resolved directly", + version: "refs/heads/feature", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/feature"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "feature-sha"}, + })) + }, + wantRef: "refs/heads/feature", + wantSHA: "feature-sha", + }, + { + name: "fully qualified tag ref not found", + version: "refs/tags/nonexistent", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/nonexistent"), + httpmock.StatusStringResponse(404, "not found")) + }, + wantErr: `tag "nonexistent" not found in monalisa/octocat-skills`, + }, + { + name: "fully qualified branch ref not found", + version: "refs/heads/nonexistent", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/nonexistent"), + httpmock.StatusStringResponse(404, "not found")) + }, + wantErr: `branch "nonexistent" not found in monalisa/octocat-skills`, + }, + { + name: "no version uses latest release with fully qualified ref", stubs: func(reg *httpmock.Registry) { reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), @@ -241,11 +365,11 @@ func TestResolveRef(t *testing.T) { "object": map[string]interface{}{"sha": "release-sha", "type": "commit"}, })) }, - wantRef: "v3.0", + wantRef: "refs/tags/v3.0", wantSHA: "release-sha", }, { - name: "no version falls back to default branch when no releases", + name: "no version falls back to default branch with fully qualified ref", stubs: func(reg *httpmock.Registry) { reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), @@ -259,12 +383,12 @@ func TestResolveRef(t *testing.T) { "object": map[string]interface{}{"sha": "branch-sha"}, })) }, - wantRef: "main", + wantRef: "refs/heads/main", wantSHA: "branch-sha", }, { name: "annotated tag dereference failure", - version: "v4.0", + version: "refs/tags/v4.0", stubs: func(reg *httpmock.Registry) { reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v4.0"), @@ -277,6 +401,24 @@ func TestResolveRef(t *testing.T) { }, wantErr: "could not dereference annotated tag", }, + { + name: "no version with server error does not fall back to default branch", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StatusStringResponse(500, "internal server error")) + }, + wantErr: "could not fetch latest release", + }, + { + name: "no version with forbidden error does not fall back to default branch", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StatusStringResponse(403, "forbidden")) + }, + wantErr: "could not fetch latest release", + }, { name: "empty tag_name in latest release falls back to default branch", stubs: func(reg *httpmock.Registry) { @@ -292,7 +434,7 @@ func TestResolveRef(t *testing.T) { "object": map[string]interface{}{"sha": "fallback-sha"}, })) }, - wantRef: "main", + wantRef: "refs/heads/main", wantSHA: "fallback-sha", }, { @@ -310,7 +452,7 @@ func TestResolveRef(t *testing.T) { "object": map[string]interface{}{"sha": "main-sha"}, })) }, - wantRef: "main", + wantRef: "refs/heads/main", wantSHA: "main-sha", }, } diff --git a/internal/skills/frontmatter/frontmatter_test.go b/internal/skills/frontmatter/frontmatter_test.go index 229eadd18..d88811ea2 100644 --- a/internal/skills/frontmatter/frontmatter_test.go +++ b/internal/skills/frontmatter/frontmatter_test.go @@ -89,13 +89,13 @@ func TestInjectGitHubMetadata(t *testing.T) { host: "github.com", owner: "monalisa", repo: "octocat-skills", - ref: "v1.0.0", + ref: "refs/tags/v1.0.0", treeSHA: "tree456", pinnedRef: "", skillPath: "skills/my-skill", wantContains: []string{ "github-repo: https://github.com/monalisa/octocat-skills", - "github-ref: v1.0.0", + "github-ref: refs/tags/v1.0.0", "github-tree-sha: tree456", "github-path: skills/my-skill", "# Body", @@ -117,7 +117,7 @@ func TestInjectGitHubMetadata(t *testing.T) { host: "github.com", owner: "monalisa", repo: "octocat-skills", - ref: "v1.0.0", + ref: "refs/tags/v1.0.0", treeSHA: "tree", pinnedRef: "v1.0.0", skillPath: "skills/my-skill", @@ -131,12 +131,13 @@ func TestInjectGitHubMetadata(t *testing.T) { host: "github.com", owner: "monalisa", repo: "octocat-skills", - ref: "v1.0.0", + ref: "refs/heads/main", treeSHA: "tree456", pinnedRef: "", skillPath: "skills/my-skill", wantContains: []string{ "github-repo: https://github.com/monalisa/octocat-skills", + "github-ref: refs/heads/main", "# Body only", }, wantNotContain: []string{"github-owner", "github-sha"}, diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index 0cc5c6131..fc65e2f0c 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -54,7 +54,6 @@ type installOptions struct { ScopeChanged bool // true when --scope was explicitly set Pin string // --pin flag Dir string // --dir flag (overrides host+scope) - All bool // --all flag Force bool // --force flag // Resolved at runtime @@ -129,47 +128,44 @@ func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra. Installed skills have GitHub tracking metadata injected into their frontmatter (%[1]sgithub-repo%[1]s, %[1]sgithub-ref%[1]s, %[1]sgithub-tree-sha%[1]s, %[1]sgithub-path%[1]s). This - metadata identifies the source repository and enables %[1]sgh skills update%[1]s + metadata identifies the source repository and enables %[1]sgh skill update%[1]s to detect changes — the tree SHA serves as an ETag for staleness checks. The %[1]sgithub-repo%[1]s value is stored as a full repository URL. When run interactively, the command prompts for any missing arguments. - When run non-interactively, %[1]srepository%[1]s is required, and either a - skill name or %[1]s--all%[1]s must be specified. + When run non-interactively, %[1]srepository%[1]s and a skill name are + required. `, "`"), Example: heredoc.Doc(` # Interactive: choose repo, skill, and agent - $ gh skills install + $ gh skill install # Choose a skill from the repo interactively - $ gh skills install github/awesome-copilot + $ gh skill install github/awesome-copilot # Install a specific skill - $ gh skills install github/awesome-copilot git-commit + $ gh skill install github/awesome-copilot git-commit # Install a specific version - $ gh skills install github/awesome-copilot git-commit@v1.2.0 - - # Install all skills from a repo - $ gh skills install github/awesome-copilot --all + $ gh skill install github/awesome-copilot git-commit@v1.2.0 # Install from a large namespaced repo by path (efficient, skips full discovery) - $ gh skills install github/awesome-copilot skills/monalisa/code-review + $ gh skill install github/awesome-copilot skills/monalisa/code-review # Install from a local directory (auto-discovers skills) - $ gh skills install ./my-skills-repo + $ gh skill install ./my-skills-repo # Install from current directory - $ gh skills install . + $ gh skill install . # Install a single local skill directory - $ gh skills install ./skills/git-commit + $ gh skill install ./skills/git-commit # Install for Claude Code at user scope - $ gh skills install github/awesome-copilot git-commit --agent claude-code --scope user + $ gh skill install github/awesome-copilot git-commit --agent claude-code --scope user # Pin to a specific git ref - $ gh skills install github/awesome-copilot git-commit --pin v2.0.0 + $ gh skill install github/awesome-copilot git-commit --pin v2.0.0 `), Aliases: []string{"add"}, Args: cobra.MaximumNArgs(2), @@ -214,7 +210,6 @@ func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra. cmdutil.StringEnumFlag(cmd, &opts.Scope, "scope", "", "project", []string{"project", "user"}, "Installation scope") cmd.Flags().StringVar(&opts.Pin, "pin", "", "pin to a specific git tag or commit SHA") cmd.Flags().StringVar(&opts.Dir, "dir", "", "install to a custom directory (overrides --agent and --scope)") - cmd.Flags().BoolVar(&opts.All, "all", false, "install all skills from the repository") cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "overwrite existing skills without prompting") return cmd @@ -327,11 +322,11 @@ func installRun(opts *installOptions) error { for _, name := range result.Installed { fmt.Fprintf(opts.IO.Out, "%s Installed %s (from %s@%s) in %s\n", - cs.SuccessIcon(), name, repoSource, resolved.Ref, friendlyDir(result.Dir)) + cs.SuccessIcon(), name, repoSource, discovery.ShortRef(resolved.Ref), friendlyDir(result.Dir)) } printFileTree(opts.IO.Out, cs, result.Dir, result.Installed) - printReviewHint(opts.IO.ErrOut, cs, repoSource, result.Installed) + printReviewHint(opts.IO.ErrOut, cs, repoSource, resolved.SHA, result.Installed) } if err != nil { @@ -444,7 +439,7 @@ func runLocalInstall(opts *installOptions) error { } printFileTree(opts.IO.Out, cs, result.Dir, result.Installed) - printReviewHint(opts.IO.ErrOut, cs, "", result.Installed) + printReviewHint(opts.IO.ErrOut, cs, "", "", result.Installed) } return nil @@ -515,7 +510,7 @@ func resolveVersion(opts *installOptions, client *api.Client, hostname string) ( if err != nil { return nil, fmt.Errorf("could not resolve version: %w", err) } - fmt.Fprintf(opts.IO.ErrOut, "Using ref %s (%s)\n", resolved.Ref, git.ShortSHA(resolved.SHA)) + fmt.Fprintf(opts.IO.ErrOut, "Using ref %s (%s)\n", discovery.ShortRef(resolved.Ref), git.ShortSHA(resolved.SHA)) return resolved, nil } @@ -575,19 +570,12 @@ func selectSkillsWithSelector(opts *installOptions, skills []discovery.Skill, ca return collisionError(ss, sel.sourceHint) } - if opts.All { - if err := checkCollisions(skills); err != nil { - return nil, err - } - return skills, nil - } - if opts.SkillName != "" { return sel.matchByName(opts, skills) } if !canPrompt { - return nil, cmdutil.FlagErrorf("must specify a skill name or use --all when not running interactively") + return nil, cmdutil.FlagErrorf("must specify a skill name when not running interactively") } if sel.fetchDescriptions != nil { @@ -743,7 +731,7 @@ func collisionError(ss []discovery.Skill, sourceHint string) error { cannot install skills with conflicting names — they would overwrite each other: %s Install these skills individually using the full name: - gh skills install %s namespace/skill-name + gh skill install %s namespace/skill-name `, discovery.FormatCollisions(collisions), sourceHint)) } @@ -1004,7 +992,9 @@ func printTreeDir(w io.Writer, cs *iostreams.ColorScheme, dir, indent string) { } // printReviewHint warns the user to review installed skills and suggests preview commands. -func printReviewHint(w io.Writer, cs *iostreams.ColorScheme, repo string, skillNames []string) { +// When sha is non-empty the suggested commands include @SHA so the user previews +// exactly the version that was installed. +func printReviewHint(w io.Writer, cs *iostreams.ColorScheme, repo, sha string, skillNames []string) { if len(skillNames) == 0 { return } @@ -1016,7 +1006,11 @@ func printReviewHint(w io.Writer, cs *iostreams.ColorScheme, repo string, skillN fmt.Fprintln(w, " Review installed content before use:") fmt.Fprintln(w) for _, name := range skillNames { - fmt.Fprintf(w, " gh skills preview %s %s\n", repo, name) + if sha != "" { + fmt.Fprintf(w, " gh skill preview %s %s@%s\n", repo, name, sha) + } else { + fmt.Fprintf(w, " gh skill preview %s %s\n", repo, name) + } } fmt.Fprintln(w) } diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index 060b146a4..b15f5a9b2 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -53,11 +53,6 @@ func TestNewCmdInstall(t *testing.T) { Force: true, }, }, - { - name: "all flag", - cli: "monalisa/skills-repo --all", - wantOpts: installOptions{SkillSource: "monalisa/skills-repo", All: true, Scope: "project"}, - }, { name: "dir flag", cli: "monalisa/skills-repo git-commit --dir ./custom-skills", @@ -142,7 +137,6 @@ func TestNewCmdInstall(t *testing.T) { assert.Equal(t, tt.wantOpts.Scope, gotOpts.Scope) assert.Equal(t, tt.wantOpts.Pin, gotOpts.Pin) assert.Equal(t, tt.wantOpts.Dir, gotOpts.Dir) - assert.Equal(t, tt.wantOpts.All, gotOpts.All) assert.Equal(t, tt.wantOpts.Force, gotOpts.Force) if tt.wantLocalPath { assert.NotEmpty(t, gotOpts.localPath, "expected localPath to be set") @@ -164,7 +158,7 @@ func TestNewCmdInstall(t *testing.T) { assert.NotEmpty(t, cmd.Example) assert.Contains(t, cmd.Aliases, "add") - for _, flag := range []string{"agent", "scope", "pin", "all", "dir", "force"} { + for _, flag := range []string{"agent", "scope", "pin", "dir", "force"} { assert.NotNil(t, cmd.Flags().Lookup(flag), "missing flag: --%s", flag) } }) @@ -287,7 +281,7 @@ func TestInstallRun(t *testing.T) { ScopeChanged: true, } }, - wantErr: "must specify a skill name or use --all", + wantErr: "must specify a skill name when not running interactively", }, { name: "remote install writes files with tracking metadata", @@ -314,36 +308,6 @@ func TestInstallRun(t *testing.T) { }, wantStdout: "Installed git-commit", }, - { - name: "remote install with --all installs multiple skills", - isTTY: true, - stubs: func(reg *httpmock.Registry) { - stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") - treeJSON := fmt.Sprintf("%s, %s", - singleSkillTreeJSON("code-review", "tree0", "blob0"), - singleSkillTreeJSON("git-commit", "tree1", "blob1")) - stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) - stubInstallFiles(reg, "monalisa", "skills-repo", "tree0", "blob0", - "---\nname: code-review\ndescription: Reviews\n---\n# B\n") - stubInstallFiles(reg, "monalisa", "skills-repo", "tree1", "blob1", - "---\nname: git-commit\ndescription: Commits\n---\n# A\n") - }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { - t.Helper() - return &installOptions{ - IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - GitClient: &git.Client{RepoDir: t.TempDir()}, - SkillSource: "monalisa/skills-repo", - All: true, - Agent: "github-copilot", - Scope: "project", - ScopeChanged: true, - Dir: t.TempDir(), - } - }, - wantStdout: "Installed", - }, { name: "remote install with --agent claude-code", isTTY: true, @@ -597,6 +561,9 @@ func TestInstallRun(t *testing.T) { name: "remote install with pin flag resolves version", isTTY: true, stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/heads/v2.0.0"), + httpmock.StatusStringResponse(404, "not found")) reg.Register( httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/tags/v2.0.0"), httpmock.StringResponse(`{"object": {"sha": "def456", "type": "commit"}}`), @@ -647,7 +614,7 @@ func TestInstallRun(t *testing.T) { } }, wantStdout: "Installed git-commit", - wantStderr: "prompt injections or malicious scripts", + wantStderr: "gh skill preview monalisa/skills-repo git-commit@abc123", }, { name: "remote install outputs file tree for TTY", @@ -678,6 +645,9 @@ func TestInstallRun(t *testing.T) { name: "remote install with inline version parses name and version", isTTY: true, stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/heads/v1.2.0"), + httpmock.StatusStringResponse(404, "not found")) reg.Register( httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/tags/v1.2.0"), httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), @@ -757,7 +727,7 @@ func TestInstallRun(t *testing.T) { }, { name: "remote install all with collisions errors", - isTTY: false, + isTTY: true, stubs: func(reg *httpmock.Registry) { stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") // Two skills with the same install name: skills/xlsx-pro and root xlsx-pro @@ -769,12 +739,17 @@ func TestInstallRun(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { t.Helper() + pm := &prompter.PrompterMock{ + MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { + return []string{allSkillsKey}, nil + }, + } return &installOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", - All: true, Agent: "github-copilot", Scope: "project", ScopeChanged: true, @@ -795,6 +770,15 @@ func TestInstallRun(t *testing.T) { `{"path": "skills/bob/xlsx-pro", "type": "tree", "sha": "treeB"}, ` + `{"path": "skills/bob/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobB"}` stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) + // Extra blob stubs consumed by FetchDescriptionsConcurrent during interactive selection. + contentA := base64.StdEncoding.EncodeToString([]byte("---\nname: xlsx-pro\ndescription: Alice\n---\n# A\n")) + contentB := base64.StdEncoding.EncodeToString([]byte("---\nname: xlsx-pro\ndescription: Bob\n---\n# B\n")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blobA"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blobA", "content": %q, "encoding": "base64"}`, contentA))) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blobB"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blobB", "content": %q, "encoding": "base64"}`, contentB))) stubInstallFiles(reg, "monalisa", "skills-repo", "treeA", "blobA", "---\nname: xlsx-pro\ndescription: Alice\n---\n# A\n") stubInstallFiles(reg, "monalisa", "skills-repo", "treeB", "blobB", @@ -802,12 +786,17 @@ func TestInstallRun(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { t.Helper() + pm := &prompter.PrompterMock{ + MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { + return []string{allSkillsKey}, nil + }, + } return &installOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", - All: true, Agent: "github-copilot", Scope: "project", ScopeChanged: true, @@ -1418,7 +1407,7 @@ func TestRunLocalInstall(t *testing.T) { IO: ios, SkillSource: sourceDir, localPath: sourceDir, - All: true, + SkillName: "git-commit", Force: true, Agent: "github-copilot", Scope: "project", @@ -1455,7 +1444,7 @@ func TestRunLocalInstall(t *testing.T) { IO: ios, SkillSource: sourceDir, localPath: sourceDir, - All: true, + SkillName: "direct-skill", Force: true, Agent: "github-copilot", Scope: "project", @@ -1468,7 +1457,7 @@ func TestRunLocalInstall(t *testing.T) { }, { name: "namespaced skills install to separate directories", - isTTY: false, + isTTY: true, setup: func(t *testing.T, sourceDir, _ string) { t.Helper() for _, ns := range []string{"alice", "bob"} { @@ -1478,11 +1467,16 @@ func TestRunLocalInstall(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { t.Helper() + pm := &prompter.PrompterMock{ + MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { + return []string{allSkillsKey}, nil + }, + } return &installOptions{ IO: ios, SkillSource: sourceDir, localPath: sourceDir, - All: true, + Prompter: pm, Force: true, Agent: "github-copilot", Scope: "project", @@ -1502,7 +1496,7 @@ func TestRunLocalInstall(t *testing.T) { }, { name: "local install with --force overwrites namespaced skill", - isTTY: false, + isTTY: true, setup: func(t *testing.T, sourceDir, targetDir string) { t.Helper() for _, ns := range []string{"alice", "bob"} { @@ -1513,11 +1507,16 @@ func TestRunLocalInstall(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { t.Helper() + pm := &prompter.PrompterMock{ + MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { + return []string{allSkillsKey}, nil + }, + } return &installOptions{ IO: ios, SkillSource: sourceDir, localPath: sourceDir, - All: true, + Prompter: pm, Force: true, Agent: "github-copilot", Scope: "project", @@ -1568,7 +1567,7 @@ func TestRunLocalInstall(t *testing.T) { IO: ios, SkillSource: sourceDir, localPath: sourceDir, - All: true, + SkillName: "anything", Agent: "github-copilot", Scope: "project", ScopeChanged: true, @@ -1597,7 +1596,7 @@ func TestRunLocalInstall(t *testing.T) { IO: ios, SkillSource: sourceDir, localPath: sourceDir, - All: true, + SkillName: "git-commit", Force: true, Agent: "github-copilot", Scope: "project", @@ -1627,7 +1626,7 @@ func TestRunLocalInstall(t *testing.T) { IO: ios, SkillSource: sourceDir, localPath: sourceDir, - All: true, + SkillName: "git-commit", Force: true, Agent: "claude-code", Scope: "project", @@ -1693,7 +1692,7 @@ func TestRunLocalInstall(t *testing.T) { IO: ios, SkillSource: sourceDir, localPath: sourceDir, - All: true, + SkillName: "git-commit", Force: true, Agent: "github-copilot", Scope: "project", @@ -1725,7 +1724,7 @@ func TestRunLocalInstall(t *testing.T) { IO: ios, SkillSource: "~/", localPath: "~/", - All: true, + SkillName: "git-commit", Force: true, Agent: "github-copilot", Scope: "project", @@ -1757,7 +1756,7 @@ func TestRunLocalInstall(t *testing.T) { IO: ios, SkillSource: "~", localPath: "~", - All: true, + SkillName: "git-commit", Force: true, Agent: "github-copilot", Scope: "project", @@ -1839,3 +1838,63 @@ func TestRunLocalInstall(t *testing.T) { }) } } + +func Test_printReviewHint(t *testing.T) { + tests := []struct { + name string + repo string + sha string + skillNames []string + wantOutput string + }{ + { + name: "remote install with SHA includes SHA in preview command", + repo: "owner/repo", + sha: "abc123def456", + skillNames: []string{"my-skill"}, + wantOutput: "gh skill preview owner/repo my-skill@abc123def456", + }, + { + name: "remote install without SHA omits SHA from preview command", + repo: "owner/repo", + sha: "", + skillNames: []string{"my-skill"}, + wantOutput: "gh skill preview owner/repo my-skill\n", + }, + { + name: "multiple skills with SHA", + repo: "owner/repo", + sha: "deadbeef", + skillNames: []string{"skill-a", "skill-b"}, + wantOutput: "skill-a@deadbeef", + }, + { + name: "local install shows generic message", + repo: "", + sha: "", + skillNames: []string{"my-skill"}, + wantOutput: "Review the installed files before use", + }, + { + name: "no skills produces no output", + repo: "owner/repo", + sha: "abc123", + skillNames: []string{}, + wantOutput: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + cs := ios.ColorScheme() + var buf strings.Builder + printReviewHint(&buf, cs, tt.repo, tt.sha, tt.skillNames) + if tt.wantOutput == "" { + assert.Empty(t, buf.String()) + } else { + assert.Contains(t, buf.String(), tt.wantOutput) + } + }) + } +} diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index 55ba125e3..ee33b04c1 100644 --- a/pkg/cmd/skills/preview/preview.go +++ b/pkg/cmd/skills/preview/preview.go @@ -30,6 +30,7 @@ type previewOptions struct { RepoArg string SkillName string + Version string // resolved from @suffix on SkillName repo ghrepo.Interface } @@ -61,13 +62,23 @@ func NewCmdPreview(f *cmdutil.Factory, runF func(*previewOptions) error) *cobra. When run with only a repository argument, lists available skills and prompts for selection. + + To preview a specific version of the skill, append @VERSION to the + skill name. The version is resolved as a git tag, branch, or commit + SHA. `), Example: heredoc.Doc(` # Preview a specific skill - $ gh skills preview github/awesome-copilot code-review + $ gh skill preview github/awesome-copilot code-review + + # Preview a skill at a specific version + $ gh skill preview github/awesome-copilot code-review@v1.2.0 + + # Preview a skill at a specific commit SHA + $ gh skill preview github/awesome-copilot code-review@abc123def456 # Browse and preview interactively - $ gh skills preview github/awesome-copilot + $ gh skill preview github/awesome-copilot `), Aliases: []string{"show"}, Args: cobra.RangeArgs(1, 2), @@ -77,6 +88,11 @@ func NewCmdPreview(f *cmdutil.Factory, runF func(*previewOptions) error) *cobra. opts.SkillName = args[1] } + if i := strings.LastIndex(opts.SkillName, "@"); i > 0 { + opts.Version = opts.SkillName[i+1:] + opts.SkillName = opts.SkillName[:i] + } + repo, err := ghrepo.FromFullName(opts.RepoArg) if err != nil { return err @@ -111,7 +127,7 @@ func previewRun(opts *previewOptions) error { apiClient := api.NewClientFromHTTP(httpClient) opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Resolving %s/%s", owner, repoName)) - resolved, err := discovery.ResolveRef(apiClient, hostname, owner, repoName, "") + resolved, err := discovery.ResolveRef(apiClient, hostname, owner, repoName, opts.Version) opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("could not resolve version: %w", err) diff --git a/pkg/cmd/skills/preview/preview_test.go b/pkg/cmd/skills/preview/preview_test.go index cd623fa06..debdfbff2 100644 --- a/pkg/cmd/skills/preview/preview_test.go +++ b/pkg/cmd/skills/preview/preview_test.go @@ -25,6 +25,7 @@ func TestNewCmdPreview(t *testing.T) { input string wantRepo string wantSkillName string + wantVersion string wantErr bool }{ { @@ -33,6 +34,20 @@ func TestNewCmdPreview(t *testing.T) { wantRepo: "github/awesome-copilot", wantSkillName: "my-skill", }, + { + name: "repo and skill with version", + input: "github/awesome-copilot my-skill@v1.2.0", + wantRepo: "github/awesome-copilot", + wantSkillName: "my-skill", + wantVersion: "v1.2.0", + }, + { + name: "repo and skill with SHA", + input: "github/awesome-copilot my-skill@abc123def456", + wantRepo: "github/awesome-copilot", + wantSkillName: "my-skill", + wantVersion: "abc123def456", + }, { name: "repo only", input: "github/awesome-copilot", @@ -78,6 +93,7 @@ func TestNewCmdPreview(t *testing.T) { require.NoError(t, err) assert.Equal(t, tt.wantRepo, gotOpts.RepoArg) assert.Equal(t, tt.wantSkillName, gotOpts.SkillName) + assert.Equal(t, tt.wantVersion, gotOpts.Version) }) } } @@ -248,6 +264,55 @@ func TestPreviewRun(t *testing.T) { }, wantErr: "must specify a skill name when not running interactively", }, + { + name: "preview with explicit version", + tty: true, + opts: &previewOptions{ + repo: ghrepo.New("github", "awesome-copilot"), + SkillName: "my-skill", + Version: "abc123def456", + }, + httpStubs: func(reg *httpmock.Registry) { + // ResolveRef with explicit version tries branch first, then tag, then commit + reg.Register( + httpmock.REST("GET", "repos/github/awesome-copilot/git/ref/heads/abc123def456"), + httpmock.StatusStringResponse(404, "not found"), + ) + reg.Register( + httpmock.REST("GET", "repos/github/awesome-copilot/git/ref/tags/abc123def456"), + httpmock.StatusStringResponse(404, "not found"), + ) + reg.Register( + httpmock.REST("GET", "repos/github/awesome-copilot/commits/abc123def456"), + httpmock.StringResponse(`{"sha": "abc123def456789012345678901234567890abcd"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/github/awesome-copilot/git/trees/abc123def456789012345678901234567890abcd"), + httpmock.StringResponse(`{ + "sha": "abc123def456789012345678901234567890abcd", + "truncated": false, + "tree": [ + {"path": "skills", "type": "tree", "sha": "tree1"}, + {"path": "skills/my-skill", "type": "tree", "sha": "treeSHA"}, + {"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blob123"} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/github/awesome-copilot/git/trees/treeSHA"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blob123", "size": 50} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/github/awesome-copilot/git/blobs/blob123"), + httpmock.StringResponse(`{"sha": "blob123", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + }, + wantStdout: "My Skill", + }, } for _, tt := range tests { diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index 96683bece..22d87bb73 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -127,13 +127,13 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*publishOptions) error) *cobra. `), Example: heredoc.Doc(` # Validate and publish interactively - $ gh skills publish + $ gh skill publish # Publish with a specific tag (non-interactive) - $ gh skills publish --tag v1.0.0 + $ gh skill publish --tag v1.0.0 # Validate only (no publish) - $ gh skills publish --dry-run + $ gh skill publish --dry-run # Validate and strip install metadata $ gh skills publish --fix @@ -621,8 +621,8 @@ func runPublishRelease(opts *publishOptions, client *api.Client, host, owner, re } fmt.Fprintf(opts.IO.Out, "%s Published %s\n", cs.SuccessIcon(), tag) - fmt.Fprintf(opts.IO.Out, "%s Install with: gh skills install %s/%s\n", cs.SuccessIcon(), owner, repo) - fmt.Fprintf(opts.IO.Out, "%s Pin with: gh skills install %s/%s --pin %s\n", cs.SuccessIcon(), owner, repo, tag) + fmt.Fprintf(opts.IO.Out, "%s Install with: gh skill install %s/%s\n", cs.SuccessIcon(), owner, repo) + fmt.Fprintf(opts.IO.Out, "%s Pin with: gh skill install %s/%s --pin %s\n", cs.SuccessIcon(), owner, repo, tag) return nil } diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go index 03874c30c..48ea9f358 100644 --- a/pkg/cmd/skills/search/search.go +++ b/pkg/cmd/skills/search/search.go @@ -89,16 +89,16 @@ func NewCmdSearch(f *cmdutil.Factory, runF func(*searchOptions) error) *cobra.Co `), Example: heredoc.Doc(` # Search for skills related to terraform - $ gh skills search terraform + $ gh skill search terraform # Search for skills from a specific owner - $ gh skills search terraform --owner hashicorp + $ gh skill search terraform --owner hashicorp # View the second page of results - $ gh skills search terraform --page 2 + $ gh skill search terraform --page 2 # Limit results to 5 - $ gh skills search terraform --limit 5 + $ gh skill search terraform --limit 5 `), Args: cmdutil.MinimumArgs(1, "cannot search: query argument required"), RunE: func(c *cobra.Command, args []string) error { diff --git a/pkg/cmd/skills/skills.go b/pkg/cmd/skills/skills.go index 8a1367314..8f9c45faf 100644 --- a/pkg/cmd/skills/skills.go +++ b/pkg/cmd/skills/skills.go @@ -10,12 +10,13 @@ import ( "github.com/spf13/cobra" ) -// NewCmdSkills returns the top-level "skills" command. +// NewCmdSkills returns the top-level "skill" command. func NewCmdSkills(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ - Use: "skills ", + Use: "skill ", Short: "Install and manage agent skills", Long: "Install and manage agent skills from GitHub repositories.", + Aliases: []string{"skills"}, GroupID: "core", } diff --git a/pkg/cmd/skills/update/update.go b/pkg/cmd/skills/update/update.go index 0a9d1b1fa..11db14a34 100644 --- a/pkg/cmd/skills/update/update.go +++ b/pkg/cmd/skills/update/update.go @@ -107,22 +107,22 @@ func NewCmdUpdate(f *cmdutil.Factory, runF func(*updateOptions) error) *cobra.Co `), Example: heredoc.Doc(` # Check and update all skills interactively - $ gh skills update + $ gh skill update # Update specific skills - $ gh skills update mcp-cli git-commit + $ gh skill update mcp-cli git-commit # Update all without prompting - $ gh skills update --all + $ gh skill update --all # Re-download all skills (restore locally modified files) - $ gh skills update --force --all + $ gh skill update --force --all # Check for updates without applying (read-only) - $ gh skills update --dry-run + $ gh skill update --dry-run # Unpin skills and update them to latest - $ gh skills update --unpin + $ gh skill update --unpin `), RunE: func(cmd *cobra.Command, args []string) error { opts.Skills = args @@ -344,12 +344,12 @@ func updateRun(opts *updateOptions) error { if u.local.treeSHA == u.newSHA { fmt.Fprintf(opts.IO.Out, " %s %s (%s/%s) %s (reinstall) [%s]\n", cs.Cyan("•"), u.local.name, u.local.owner, u.local.repo, - git.ShortSHA(u.newSHA), u.resolved.Ref) + git.ShortSHA(u.newSHA), discovery.ShortRef(u.resolved.Ref)) } else { fmt.Fprintf(opts.IO.Out, " %s %s (%s/%s) %s → %s [%s]\n", cs.Cyan("•"), u.local.name, u.local.owner, u.local.repo, cs.Muted(git.ShortSHA(u.local.treeSHA)), git.ShortSHA(u.newSHA), - u.resolved.Ref) + discovery.ShortRef(u.resolved.Ref)) } } fmt.Fprintln(opts.IO.ErrOut) From 45d0ec0b5173f6fb2e9a118276f84b982af56740 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Wed, 8 Apr 2026 19:14:11 +0100 Subject: [PATCH 11/27] address review comments Co-authored-by: Sam Morrow --- .gitignore | 1 + .../testdata/skills/skills-install-all.txtar | 5 - .../skills/skills-install-force.txtar | 6 +- .../skills/skills-install-from-local.txtar | 15 + .../skills/skills-install-invalid-agent.txtar | 4 +- .../skills/skills-install-invalid-repo.txtar | 2 +- .../skills/skills-install-nested-files.txtar | 2 +- .../skills-install-nonexistent-skill.txtar | 2 +- .../testdata/skills/skills-install-pin.txtar | 4 +- .../skills/skills-install-scope.txtar | 8 +- .../testdata/skills/skills-install.txtar | 8 +- .../skills-preview-noninteractive.txtar | 2 +- .../testdata/skills/skills-preview.txtar | 4 +- .../skills/skills-publish-dry-run.txtar | 10 +- .../skills/skills-publish-lifecycle.txtar | 12 +- .../skills/skills-search-noresults.txtar | 2 +- .../testdata/skills/skills-search-page.txtar | 2 +- .../testdata/skills/skills-search.txtar | 6 +- .../skills/skills-update-noinstalled.txtar | 2 +- .../testdata/skills/skills-update.txtar | 10 +- git/client.go | 20 +- git/client_test.go | 120 +++++++ go.mod | 2 +- internal/flock/flock.go | 8 + internal/flock/flock_test.go | 99 ++++++ internal/flock/flock_unix.go | 32 ++ internal/flock/flock_windows.go | 41 +++ internal/skills/discovery/discovery.go | 135 +++++--- internal/skills/discovery/discovery_test.go | 39 ++- internal/skills/installer/installer.go | 42 +-- internal/skills/installer/installer_test.go | 2 +- internal/skills/lockfile/lockfile.go | 126 +++---- internal/skills/lockfile/lockfile_test.go | 63 +--- internal/skills/registry/registry.go | 8 +- pkg/cmd/root/root.go | 2 +- pkg/cmd/skills/install/install.go | 219 ++++++------ pkg/cmd/skills/install/install_test.go | 322 +++++++++++------- .../skills/install/install_windows_test.go | 63 ---- pkg/cmd/skills/preview/preview.go | 28 +- pkg/cmd/skills/preview/preview_test.go | 34 +- pkg/cmd/skills/publish/publish.go | 76 +++-- pkg/cmd/skills/publish/publish_test.go | 157 +++++---- pkg/cmd/skills/search/search.go | 26 +- pkg/cmd/skills/search/search_test.go | 52 +-- pkg/cmd/skills/skills.go | 28 +- pkg/cmd/skills/update/update.go | 54 +-- pkg/cmd/skills/update/update_test.go | 86 ++--- 47 files changed, 1204 insertions(+), 787 deletions(-) delete mode 100644 acceptance/testdata/skills/skills-install-all.txtar create mode 100644 acceptance/testdata/skills/skills-install-from-local.txtar create mode 100644 internal/flock/flock.go create mode 100644 internal/flock/flock_test.go create mode 100644 internal/flock/flock_unix.go create mode 100644 internal/flock/flock_windows.go delete mode 100644 pkg/cmd/skills/install/install_windows_test.go diff --git a/.gitignore b/.gitignore index b82a00c72..ffcbbb6c5 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ *~ vendor/ +gh diff --git a/acceptance/testdata/skills/skills-install-all.txtar b/acceptance/testdata/skills/skills-install-all.txtar deleted file mode 100644 index 6efd7747e..000000000 --- a/acceptance/testdata/skills/skills-install-all.txtar +++ /dev/null @@ -1,5 +0,0 @@ -# Install all skills from a repo with mixed conventions (skills/ + plugins/) -# This previously failed with "conflicting names" — now uses namespaced dirs -exec gh skills install github/awesome-copilot --all --scope user --force --agent github-copilot -stdout 'Installed' -! stderr 'conflicting names' diff --git a/acceptance/testdata/skills/skills-install-force.txtar b/acceptance/testdata/skills/skills-install-force.txtar index 5623fce84..e6bd520b9 100644 --- a/acceptance/testdata/skills/skills-install-force.txtar +++ b/acceptance/testdata/skills/skills-install-force.txtar @@ -1,11 +1,11 @@ # Install with --force should overwrite an existing skill without error -exec gh skills install github/awesome-copilot git-commit --force --dir $WORK/force-test +exec gh skill install github/awesome-copilot git-commit --force --dir $WORK/force-test stdout 'Installed git-commit' # Install again with --force — should succeed (overwrite) -exec gh skills install github/awesome-copilot git-commit --force --dir $WORK/force-test +exec gh skill install github/awesome-copilot git-commit --force --dir $WORK/force-test stdout 'Installed git-commit' # Without --force, non-interactive should fail when skill exists -! exec gh skills install github/awesome-copilot git-commit --dir $WORK/force-test +! exec gh skill install github/awesome-copilot git-commit --dir $WORK/force-test stderr 'already installed' diff --git a/acceptance/testdata/skills/skills-install-from-local.txtar b/acceptance/testdata/skills/skills-install-from-local.txtar new file mode 100644 index 000000000..0b003fd3e --- /dev/null +++ b/acceptance/testdata/skills/skills-install-from-local.txtar @@ -0,0 +1,15 @@ +# Install from a local directory using --from-local +exec gh skill install --from-local $WORK/local-repo git-commit --dir $WORK/output --force +stdout 'Installed git-commit' + +# Verify the skill was copied +exists $WORK/output/git-commit/SKILL.md +grep 'local-path' $WORK/output/git-commit/SKILL.md + +-- local-repo/skills/git-commit/SKILL.md -- +--- +name: git-commit +description: Write good git commits +--- +# Git Commit +Body content. diff --git a/acceptance/testdata/skills/skills-install-invalid-agent.txtar b/acceptance/testdata/skills/skills-install-invalid-agent.txtar index 23883524f..7e85a9fae 100644 --- a/acceptance/testdata/skills/skills-install-invalid-agent.txtar +++ b/acceptance/testdata/skills/skills-install-invalid-agent.txtar @@ -1,4 +1,4 @@ # Invalid agent ID should error with valid options -! exec gh skills install github/awesome-copilot git-commit --agent bogus-agent --force -stderr 'unknown agent' +! exec gh skill install github/awesome-copilot git-commit --agent bogus-agent --force +stderr 'invalid argument' stderr 'github-copilot' diff --git a/acceptance/testdata/skills/skills-install-invalid-repo.txtar b/acceptance/testdata/skills/skills-install-invalid-repo.txtar index 26ecbc718..2b59582e1 100644 --- a/acceptance/testdata/skills/skills-install-invalid-repo.txtar +++ b/acceptance/testdata/skills/skills-install-invalid-repo.txtar @@ -1,3 +1,3 @@ # Nonexistent repo should error -! exec gh skills install nonexistent-owner-xyz/nonexistent-repo-abc --force --dir $WORK/tmp +! exec gh skill install nonexistent-owner-xyz/nonexistent-repo-abc --force --dir $WORK/tmp stderr 'Not Found' diff --git a/acceptance/testdata/skills/skills-install-nested-files.txtar b/acceptance/testdata/skills/skills-install-nested-files.txtar index c5cf19e56..c4fe085e4 100644 --- a/acceptance/testdata/skills/skills-install-nested-files.txtar +++ b/acceptance/testdata/skills/skills-install-nested-files.txtar @@ -1,3 +1,3 @@ # Install a skill that has nested subdirectories and verify file tree -exec gh skills install github/awesome-copilot git-commit --force --dir $WORK/nested-test +exec gh skill install github/awesome-copilot git-commit --force --dir $WORK/nested-test exists $WORK/nested-test/git-commit/SKILL.md diff --git a/acceptance/testdata/skills/skills-install-nonexistent-skill.txtar b/acceptance/testdata/skills/skills-install-nonexistent-skill.txtar index 23f72cee8..44187c4ff 100644 --- a/acceptance/testdata/skills/skills-install-nonexistent-skill.txtar +++ b/acceptance/testdata/skills/skills-install-nonexistent-skill.txtar @@ -1,3 +1,3 @@ # Installing a skill that doesn't exist in a valid repo should error -! exec gh skills install github/awesome-copilot nonexistent-skill-xyz --force --dir $WORK/tmp +! exec gh skill install github/awesome-copilot nonexistent-skill-xyz --force --dir $WORK/tmp stderr 'not found' diff --git a/acceptance/testdata/skills/skills-install-pin.txtar b/acceptance/testdata/skills/skills-install-pin.txtar index 43d780e3e..7c87e4b33 100644 --- a/acceptance/testdata/skills/skills-install-pin.txtar +++ b/acceptance/testdata/skills/skills-install-pin.txtar @@ -1,7 +1,7 @@ # Install with --pin to a specific ref -exec gh skills install github/awesome-copilot git-commit --scope user --force --pin main +exec gh skill install github/awesome-copilot git-commit --scope user --force --pin main stdout 'Installed git-commit' # Install without --pin should resolve latest version -exec gh skills install github/awesome-copilot git-commit --scope user --force +exec gh skill install github/awesome-copilot git-commit --scope user --force stdout 'Installed git-commit' diff --git a/acceptance/testdata/skills/skills-install-scope.txtar b/acceptance/testdata/skills/skills-install-scope.txtar index da2df19ea..52270178a 100644 --- a/acceptance/testdata/skills/skills-install-scope.txtar +++ b/acceptance/testdata/skills/skills-install-scope.txtar @@ -1,9 +1,9 @@ -# Install with --scope project writes to the git repo's .github/skills/ +# Install with --scope project writes to the git repo's .agents/skills/ exec git init --initial-branch=main $WORK/myrepo cd $WORK/myrepo -exec gh skills install github/awesome-copilot git-commit --scope project --force --agent github-copilot -exists $WORK/myrepo/.github/skills/git-commit/SKILL.md +exec gh skill install github/awesome-copilot git-commit --scope project --force --agent github-copilot +exists $WORK/myrepo/.agents/skills/git-commit/SKILL.md # Install with --scope user writes to home directory -exec gh skills install github/awesome-copilot git-commit --scope user --force --agent github-copilot +exec gh skill install github/awesome-copilot git-commit --scope user --force --agent github-copilot exists $HOME/.copilot/skills/git-commit/SKILL.md diff --git a/acceptance/testdata/skills/skills-install.txtar b/acceptance/testdata/skills/skills-install.txtar index 183f930fd..c365cb833 100644 --- a/acceptance/testdata/skills/skills-install.txtar +++ b/acceptance/testdata/skills/skills-install.txtar @@ -1,20 +1,20 @@ # Install a single skill from a public repo -exec gh skills install github/awesome-copilot git-commit --scope user --force --agent github-copilot +exec gh skill install github/awesome-copilot git-commit --scope user --force --agent github-copilot stdout 'Installed git-commit' # Verify SKILL.md has frontmatter metadata injected exists $HOME/.copilot/skills/git-commit/SKILL.md -grep 'github-owner' $HOME/.copilot/skills/git-commit/SKILL.md grep 'github-repo' $HOME/.copilot/skills/git-commit/SKILL.md +grep 'github-tree-sha' $HOME/.copilot/skills/git-commit/SKILL.md # Verify lockfile was written exists $HOME/.agents/.skill-lock.json grep 'git-commit' $HOME/.agents/.skill-lock.json # Install with --dir to a custom directory -exec gh skills install github/awesome-copilot git-commit --force --dir $WORK/custom-skills +exec gh skill install github/awesome-copilot git-commit --force --dir $WORK/custom-skills stdout 'Installed git-commit' # Verify the skill was written to the custom directory exists $WORK/custom-skills/git-commit/SKILL.md -grep 'github-owner' $WORK/custom-skills/git-commit/SKILL.md +grep 'github-repo' $WORK/custom-skills/git-commit/SKILL.md diff --git a/acceptance/testdata/skills/skills-preview-noninteractive.txtar b/acceptance/testdata/skills/skills-preview-noninteractive.txtar index 939df0ab6..7c276b8d3 100644 --- a/acceptance/testdata/skills/skills-preview-noninteractive.txtar +++ b/acceptance/testdata/skills/skills-preview-noninteractive.txtar @@ -1,3 +1,3 @@ # Preview with repo only and non-interactive should error -! exec gh skills preview github/awesome-copilot +! exec gh skill preview github/awesome-copilot stderr 'must specify a skill name' diff --git a/acceptance/testdata/skills/skills-preview.txtar b/acceptance/testdata/skills/skills-preview.txtar index 3834c340c..be1be5244 100644 --- a/acceptance/testdata/skills/skills-preview.txtar +++ b/acceptance/testdata/skills/skills-preview.txtar @@ -1,9 +1,9 @@ # Preview renders skill content and file tree -exec gh skills preview github/awesome-copilot git-commit +exec gh skill preview github/awesome-copilot git-commit stdout 'SKILL.md' # Verify actual content is rendered, not just the filename stdout 'git-commit/' # Preview a skill that doesn't exist should error -! exec gh skills preview github/awesome-copilot nonexistent-skill-xyz +! exec gh skill preview github/awesome-copilot nonexistent-skill-xyz stderr 'not found' diff --git a/acceptance/testdata/skills/skills-publish-dry-run.txtar b/acceptance/testdata/skills/skills-publish-dry-run.txtar index 39c0f234d..2dea21d67 100644 --- a/acceptance/testdata/skills/skills-publish-dry-run.txtar +++ b/acceptance/testdata/skills/skills-publish-dry-run.txtar @@ -1,21 +1,21 @@ # Publish dry-run from a directory with no skills/ should fail gracefully -! exec gh skills publish --dry-run $WORK +! exec gh skill publish --dry-run $WORK stderr 'no skills/ directory found' # Publish dry-run against a valid skill directory should succeed -exec gh skills publish --dry-run $WORK/test-repo +exec gh skill publish --dry-run $WORK/test-repo stdout 'hello-world' # Validate alias should work identically -exec gh skills validate --dry-run $WORK/test-repo +exec gh skill validate --dry-run $WORK/test-repo stdout 'hello-world' # Publish dry-run with --tag -exec gh skills publish --dry-run --tag v1.0.0 $WORK/test-repo +exec gh skill publish --dry-run --tag v1.0.0 $WORK/test-repo stdout 'hello-world' # Publish dry-run with --fix -exec gh skills publish --dry-run --fix $WORK/test-repo +exec gh skill publish --dry-run --fix $WORK/test-repo stdout 'hello-world' -- test-repo/skills/hello-world/SKILL.md -- diff --git a/acceptance/testdata/skills/skills-publish-lifecycle.txtar b/acceptance/testdata/skills/skills-publish-lifecycle.txtar index 0e8a03a1d..d3d6f0a3a 100644 --- a/acceptance/testdata/skills/skills-publish-lifecycle.txtar +++ b/acceptance/testdata/skills/skills-publish-lifecycle.txtar @@ -20,31 +20,31 @@ exec git commit -m 'Add test skill' exec git push origin main # Publish with a tag -exec gh skills publish --tag v0.1.0 +exec gh skill publish --tag v0.1.0 # Verify the release was created on GitHub exec gh release view v0.1.0 stdout 'v0.1.0' # Install from our test repo -exec gh skills install $ORG/$SCRIPT_NAME-$RANDOM_STRING hello-world --scope user --force +exec gh skill install $ORG/$SCRIPT_NAME-$RANDOM_STRING hello-world --scope user --force stdout 'Installed hello-world' # Verify installed files exist with correct metadata exists $HOME/.copilot/skills/hello-world/SKILL.md exists $HOME/.copilot/skills/hello-world/scripts/setup.sh -grep 'github-owner' $HOME/.copilot/skills/hello-world/SKILL.md +grep 'github-repo' $HOME/.copilot/skills/hello-world/SKILL.md # Install with --pin -exec gh skills install $ORG/$SCRIPT_NAME-$RANDOM_STRING hello-world --scope user --force --pin v0.1.0 +exec gh skill install $ORG/$SCRIPT_NAME-$RANDOM_STRING hello-world --scope user --force --pin v0.1.0 stdout 'Installed hello-world' # Preview from our test repo -exec gh skills preview $ORG/$SCRIPT_NAME-$RANDOM_STRING hello-world +exec gh skill preview $ORG/$SCRIPT_NAME-$RANDOM_STRING hello-world stdout 'Hello World' # Update dry-run should find installed skill -exec gh skills update --dry-run --all +exec gh skill update --dry-run --all stderr 'up to date' -- skill.md -- diff --git a/acceptance/testdata/skills/skills-search-noresults.txtar b/acceptance/testdata/skills/skills-search-noresults.txtar index 31f8293f0..425666556 100644 --- a/acceptance/testdata/skills/skills-search-noresults.txtar +++ b/acceptance/testdata/skills/skills-search-noresults.txtar @@ -1,4 +1,4 @@ # Search for something unlikely to exist returns empty stdout # (NoResultsError is silent in non-TTY — exits 0 with no output) -exec gh skills search zzzznonexistenttotallyfakeskillxyz123 +exec gh skill search zzzznonexistenttotallyfakeskillxyz123 ! stdout . diff --git a/acceptance/testdata/skills/skills-search-page.txtar b/acceptance/testdata/skills/skills-search-page.txtar index 71bc6f1de..30c044f78 100644 --- a/acceptance/testdata/skills/skills-search-page.txtar +++ b/acceptance/testdata/skills/skills-search-page.txtar @@ -1,3 +1,3 @@ # Pagination returns results on page 2 -exec gh skills search copilot --page 2 +exec gh skill search copilot --page 2 stdout 'copilot' diff --git a/acceptance/testdata/skills/skills-search.txtar b/acceptance/testdata/skills/skills-search.txtar index eb4759a41..5e8c77442 100644 --- a/acceptance/testdata/skills/skills-search.txtar +++ b/acceptance/testdata/skills/skills-search.txtar @@ -1,12 +1,12 @@ # Search for skills matching a query -exec gh skills search copilot +exec gh skill search copilot stdout 'copilot' # Search with JSON output -exec gh skills search copilot --json skillName,repo --limit 1 +exec gh skill search copilot --json skillName,repo --limit 1 stdout '"skillName"' stdout '"repo"' # Search with a short query should error -! exec gh skills search a +! exec gh skill search a stderr 'at least' diff --git a/acceptance/testdata/skills/skills-update-noinstalled.txtar b/acceptance/testdata/skills/skills-update-noinstalled.txtar index 7f24291ba..7fd19541b 100644 --- a/acceptance/testdata/skills/skills-update-noinstalled.txtar +++ b/acceptance/testdata/skills/skills-update-noinstalled.txtar @@ -1,5 +1,5 @@ # Update with no installed skills should report appropriately -exec gh skills update --dry-run --all --dir $WORK/empty-dir +exec gh skill update --dry-run --all --dir $WORK/empty-dir stderr 'No installed skills found' -- empty-dir/.gitkeep -- diff --git a/acceptance/testdata/skills/skills-update.txtar b/acceptance/testdata/skills/skills-update.txtar index 7041c84b4..52933a5f8 100644 --- a/acceptance/testdata/skills/skills-update.txtar +++ b/acceptance/testdata/skills/skills-update.txtar @@ -1,14 +1,13 @@ # Dry-run update should find the installed skill and report status -exec gh skills update --dry-run --all --dir $WORK/skills-dir -stderr 'update' +exec gh skill update --dry-run --all --dir $WORK/skills-dir stdout 'git-commit' # Force update should re-download and rewrite files -exec gh skills update --force --all --dir $WORK/skills-dir +exec gh skill update --force --all --dir $WORK/skills-dir stdout 'Updated' # Verify the SKILL.md was rewritten with real content (not our placeholder) -grep 'github-owner' $WORK/skills-dir/git-commit/SKILL.md +grep 'github-repo' $WORK/skills-dir/git-commit/SKILL.md ! grep 'Test skill content' $WORK/skills-dir/git-commit/SKILL.md -- skills-dir/git-commit/SKILL.md -- @@ -16,8 +15,7 @@ grep 'github-owner' $WORK/skills-dir/git-commit/SKILL.md name: git-commit description: Git commit helper metadata: - github-owner: github - github-repo: awesome-copilot + github-repo: https://github.com/github/awesome-copilot.git github-tree-sha: 0000000000000000000000000000000000000000 github-path: skills/git-commit --- diff --git a/git/client.go b/git/client.go index 22c4eff16..7f2487fce 100644 --- a/git/client.go +++ b/git/client.go @@ -715,7 +715,7 @@ func (c *Client) IsLocalGitRepo(ctx context.Context) (bool, error) { // RemoteURL returns the fetch URL configured for the named remote. func (c *Client) RemoteURL(ctx context.Context, name string) (string, error) { - cmd, err := c.Command(ctx, "remote", "get-url", name) + cmd, err := c.Command(ctx, "remote", "get-url", "--", name) if err != nil { return "", err } @@ -727,13 +727,23 @@ func (c *Client) RemoteURL(ctx context.Context, name string) (string, error) { } // IsIgnored reports whether the given path is ignored by .gitignore rules. -func (c *Client) IsIgnored(ctx context.Context, path string) bool { - cmd, err := c.Command(ctx, "check-ignore", "-q", path) +// Returns an error for fatal git failures (e.g. path outside repository). +func (c *Client) IsIgnored(ctx context.Context, path string) (bool, error) { + cmd, err := c.Command(ctx, "check-ignore", "-q", "--", path) if err != nil { - return false + return false, err } _, err = cmd.Output() - return err == nil + if err == nil { + return true, nil + } + // Exit 1 here means we can confirm the path is not ignored. + // Any other error is a real git error. + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { + return false, nil + } + return false, err } // ShortSHA returns the first 8 characters of a SHA hash for display purposes. diff --git a/git/client_test.go b/git/client_test.go index f59b26077..7ffee2dc9 100644 --- a/git/client_test.go +++ b/git/client_test.go @@ -2164,3 +2164,123 @@ func createMockedCommandContext(t *testing.T, commands mockedCommands) commandCt return cmd } } + +func TestClientRemoteURL(t *testing.T) { + tests := []struct { + name string + cmdExitStatus int + cmdStdout string + cmdStderr string + wantCmdArgs string + wantURL string + wantErrorMsg string + }{ + { + name: "returns remote URL", + cmdStdout: "https://github.com/monalisa/skills-repo.git\n", + wantCmdArgs: "path/to/git remote get-url -- origin", + wantURL: "https://github.com/monalisa/skills-repo.git", + }, + { + name: "git error", + cmdExitStatus: 1, + cmdStderr: "fatal: No such remote 'nonexistent'", + wantCmdArgs: "path/to/git remote get-url -- nonexistent", + wantErrorMsg: "failed to run git: fatal: No such remote 'nonexistent'", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr) + client := Client{ + GitPath: "path/to/git", + commandContext: cmdCtx, + } + remoteName := "origin" + if tt.wantErrorMsg != "" { + remoteName = "nonexistent" + } + url, err := client.RemoteURL(context.Background(), remoteName) + assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " ")) + if tt.wantErrorMsg == "" { + assert.NoError(t, err) + assert.Equal(t, tt.wantURL, url) + } else { + assert.EqualError(t, err, tt.wantErrorMsg) + } + }) + } + + // Covers the early return in RemoteURL when Command() itself fails. + // (e.g. git binary not resolvable). + t.Run("returns error when git has a fatal error", func(t *testing.T) { + t.Setenv("PATH", "") + client := Client{} + _, err := client.RemoteURL(context.Background(), "origin") + assert.Error(t, err) + }) +} + +func TestClientIsIgnored(t *testing.T) { + tests := []struct { + name string + cmdExitStatus int + cmdStdout string + cmdStderr string + wantCmdArgs string + wantIgnored bool + wantErr bool + }{ + { + name: "path is ignored", + wantCmdArgs: "path/to/git check-ignore -q -- .github/skills", + wantIgnored: true, + }, + { + name: "path is not ignored", + cmdExitStatus: 1, + wantCmdArgs: "path/to/git check-ignore -q -- .github/skills", + wantIgnored: false, + }, + { + name: "fatal git error", + cmdExitStatus: 128, + cmdStderr: "fatal: not a git repository", + wantCmdArgs: "path/to/git check-ignore -q -- .github/skills", + wantIgnored: false, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr) + client := Client{ + GitPath: "path/to/git", + commandContext: cmdCtx, + } + ignored, err := client.IsIgnored(context.Background(), ".github/skills") + assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " ")) + assert.Equal(t, tt.wantIgnored, ignored) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } + + // Covers the early return in IsIgnored when Command() itself fails + // (e.g. git binary not resolvable). + t.Run("returns error when git has a fatal error", func(t *testing.T) { + t.Setenv("PATH", "") + client := Client{} + ignored, err := client.IsIgnored(context.Background(), ".github/skills") + assert.False(t, ignored) + assert.Error(t, err) + }) +} + +func TestShortSHA(t *testing.T) { + assert.Equal(t, "abc123de", ShortSHA("abc123def456789")) + assert.Equal(t, "short", ShortSHA("short")) +} diff --git a/go.mod b/go.mod index 615b1ebf4..0fc0b1a5e 100644 --- a/go.mod +++ b/go.mod @@ -57,6 +57,7 @@ require ( github.com/zalando/go-keyring v0.2.8 golang.org/x/crypto v0.50.0 golang.org/x/sync v0.20.0 + golang.org/x/sys v0.43.0 golang.org/x/term v0.42.0 golang.org/x/text v0.36.0 google.golang.org/grpc v1.80.0 @@ -182,7 +183,6 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/mod v0.34.0 // indirect golang.org/x/net v0.53.0 // indirect - golang.org/x/sys v0.43.0 // indirect golang.org/x/tools v0.43.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect diff --git a/internal/flock/flock.go b/internal/flock/flock.go new file mode 100644 index 000000000..6d5af9f01 --- /dev/null +++ b/internal/flock/flock.go @@ -0,0 +1,8 @@ +package flock + +import "errors" + +// ErrLocked is returned when the file is already locked by another process. +// Callers can check for this to distinguish contention from permanent errors. +// This is intended to be an OS-agnostic sentinel error. +var ErrLocked = errors.New("file is locked by another process") diff --git a/internal/flock/flock_test.go b/internal/flock/flock_test.go new file mode 100644 index 000000000..69b3a73b5 --- /dev/null +++ b/internal/flock/flock_test.go @@ -0,0 +1,99 @@ +package flock_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/cli/cli/v2/internal/flock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTryLock(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) string // returns lock path + wantErr error + verify func(t *testing.T, f *os.File) + }{ + { + name: "acquires lock and returns writable file handle", + setup: func(t *testing.T) string { + return filepath.Join(t.TempDir(), "test.lock") + }, + verify: func(t *testing.T, f *os.File) { + t.Helper() + _, err := f.WriteString("hello") + require.NoError(t, err) + _, err = f.Seek(0, 0) + require.NoError(t, err) + buf := make([]byte, 5) + n, err := f.Read(buf) + assert.NoError(t, err) + assert.Equal(t, "hello", string(buf[:n])) + }, + }, + { + name: "creates lock file if it does not exist", + setup: func(t *testing.T) string { + dir := filepath.Join(t.TempDir(), "subdir") + require.NoError(t, os.MkdirAll(dir, 0o755)) + return filepath.Join(dir, "new.lock") + }, + verify: func(t *testing.T, f *os.File) { + t.Helper() + _, err := os.Stat(f.Name()) + assert.NoError(t, err) + }, + }, + { + name: "second lock on same path returns ErrLocked", + setup: func(t *testing.T) string { + lockPath := filepath.Join(t.TempDir(), "contended.lock") + _, unlock, err := flock.TryLock(lockPath) + require.NoError(t, err) + t.Cleanup(unlock) + return lockPath + }, + wantErr: flock.ErrLocked, + }, + { + name: "lock succeeds after unlock", + setup: func(t *testing.T) string { + lockPath := filepath.Join(t.TempDir(), "reuse.lock") + _, unlock, err := flock.TryLock(lockPath) + require.NoError(t, err) + unlock() + return lockPath + }, + }, + { + name: "fails on non-existent directory", + setup: func(t *testing.T) string { + return filepath.Join(t.TempDir(), "no", "such", "dir", "test.lock") + }, + wantErr: os.ErrNotExist, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lockPath := tt.setup(t) + + f, unlock, err := flock.TryLock(lockPath) + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + return + } + + require.NoError(t, err) + require.NotNil(t, f) + defer unlock() + + if tt.verify != nil { + tt.verify(t, f) + } + }) + } +} diff --git a/internal/flock/flock_unix.go b/internal/flock/flock_unix.go new file mode 100644 index 000000000..73f8b1557 --- /dev/null +++ b/internal/flock/flock_unix.go @@ -0,0 +1,32 @@ +//go:build !windows + +package flock + +import ( + "errors" + "os" + "syscall" +) + +// TryLock attempts to acquire an exclusive, non-blocking flock on the given path. +// Returns the locked file and an unlock function on success. The caller should +// read/write through the returned file to avoid platform differences with +// mandatory locking on Windows. +// Returns ErrLocked if the file is already locked by another process. +func TryLock(path string) (f *os.File, unlock func(), err error) { + f, err = os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644) + if err != nil { + return nil, nil, err + } + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + _ = f.Close() + if errors.Is(err, syscall.EWOULDBLOCK) { + return nil, nil, ErrLocked + } + return nil, nil, err + } + return f, func() { + _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + _ = f.Close() + }, nil +} diff --git a/internal/flock/flock_windows.go b/internal/flock/flock_windows.go new file mode 100644 index 000000000..4795af083 --- /dev/null +++ b/internal/flock/flock_windows.go @@ -0,0 +1,41 @@ +//go:build windows + +package flock + +import ( + "errors" + "os" + + "golang.org/x/sys/windows" +) + +// TryLock attempts to acquire an exclusive, non-blocking lock on the given path. +// Returns the locked file and an unlock function on success. The caller should +// read/write through the returned file to avoid Windows mandatory lock conflicts. +// Returns ErrLocked if the file is already locked by another process. +func TryLock(path string) (f *os.File, unlock func(), err error) { + f, err = os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644) + if err != nil { + return nil, nil, err + } + ol := new(windows.Overlapped) + handle := windows.Handle(f.Fd()) + err = windows.LockFileEx( + handle, + windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, + 0, + 1, 0, + ol, + ) + if err != nil { + _ = f.Close() + if errors.Is(err, windows.ERROR_LOCK_VIOLATION) { + return nil, nil, ErrLocked + } + return nil, nil, err + } + return f, func() { + _ = windows.UnlockFileEx(handle, 0, 1, 0, ol) + _ = f.Close() + }, nil +} diff --git a/internal/skills/discovery/discovery.go b/internal/skills/discovery/discovery.go index 4e54fd5e3..84f2aa596 100644 --- a/internal/skills/discovery/discovery.go +++ b/internal/skills/discovery/discovery.go @@ -6,12 +6,15 @@ import ( "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" @@ -21,6 +24,17 @@ import ( // 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. @@ -127,7 +141,7 @@ type repoResponse struct { } // ResolveRef determines the git ref to use for a given owner/repo. -// Priority: explicit version → latest release tag → default branch. +// 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) @@ -166,19 +180,27 @@ func resolveExplicitRef(client *api.Client, host, owner, repo, ref string) (*Res } // 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", owner, repo, ref) + 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) @@ -187,7 +209,7 @@ func resolveExplicitRef(client *api.Client, host, owner, repo, ref string) (*Res // 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", owner, repo, tag) + 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"` @@ -199,7 +221,7 @@ func resolveTagRef(client *api.Client, host, owner, repo, tag string) (*Resolved } sha := refResp.Object.SHA if refResp.Object.Type == "tag" { - derefPath := fmt.Sprintf("repos/%s/%s/git/tags/%s", owner, repo, sha) + 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"` @@ -215,7 +237,7 @@ func resolveTagRef(client *api.Client, host, owner, repo, tag string) (*Resolved // 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", owner, repo, branch) + 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"` @@ -227,6 +249,12 @@ func resolveBranchRef(client *api.Client, host, owner, repo, branch string) (*Re 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. @@ -237,16 +265,15 @@ type noReleasesError struct { 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", owner, repo) + apiPath := fmt.Sprintf("repos/%s/%s/releases/latest", url.PathEscape(owner), url.PathEscape(repo)) var release releaseResponse if err := client.REST(host, "GET", apiPath, nil, &release); err != nil { - // A 404 means the repository has no releases — this is the + // 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. - var httpErr api.HTTPError - if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + 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) @@ -258,14 +285,14 @@ func resolveLatestRelease(client *api.Client, host, owner, repo string) (*Resolv } func resolveDefaultBranch(client *api.Client, host, owner, repo string) (*ResolvedRef, error) { - apiPath := fmt.Sprintf("repos/%s/%s", owner, repo) + apiPath := fmt.Sprintf("repos/%s/%s", url.PathEscape(owner), url.PathEscape(repo)) var repoResp repoResponse if err := client.REST(host, "GET", apiPath, nil, &repoResp); err != nil { return nil, fmt.Errorf("could not determine default branch: %w", err) } branch := repoResp.DefaultBranch if branch == "" { - branch = "main" + return nil, fmt.Errorf("could not determine default branch for %s/%s", owner, repo) } return resolveBranchRef(client, host, owner, repo, branch) } @@ -333,18 +360,14 @@ func matchSkillConventions(entry treeEntry) *skillMatch { // DiscoverSkills finds all skills in a repository at the given commit SHA. func DiscoverSkills(client *api.Client, host, owner, repo, commitSHA string) ([]Skill, error) { - apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s?recursive=true", owner, repo, commitSHA) + 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, fmt.Errorf( - "repository tree for %s/%s is too large for full discovery\n"+ - " Use path-based install instead: gh skill install %s/%s skills/", - owner, repo, owner, repo, - ) + return nil, &TreeTooLargeError{Owner: owner, Repo: repo} } treeSHAs := make(map[string]string) @@ -393,6 +416,10 @@ func DiscoverSkills(client *api.Client, host, owner, repo, commitSHA string) ([] }) } + sort.SliceStable(skills, func(i, j int) bool { + return skills[i].DisplayName() < skills[j].DisplayName() + }) + return skills, nil } @@ -425,33 +452,31 @@ func FetchDescriptionsConcurrent(client *api.Client, host, owner, repo string, s } const maxWorkers = 10 - sem := make(chan struct{}, maxWorkers) - var mu sync.Mutex - done := 0 - var wg sync.WaitGroup - for i := range skills { - if skills[i].Description != "" { - continue - } - wg.Add(1) - go func(idx int) { - defer wg.Done() - sem <- struct{}{} - defer func() { <-sem }() + var done atomic.Int32 - desc := fetchDescription(client, host, owner, repo, &skills[idx]) + jobs := make(chan *Skill) - mu.Lock() - skills[idx].Description = desc - done++ - d := done - mu.Unlock() - if onProgress != nil { - onProgress(d, total) + 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) + } } - }(i) + }) } + + for i := range skills { + if skills[i].Description == "" { + jobs <- &skills[i] + } + } + close(jobs) wg.Wait() } @@ -466,7 +491,7 @@ func DiscoverSkillByPath(client *api.Client, host, owner, repo, commitSHA, skill } parentPath := path.Dir(skillPath) - apiPath := fmt.Sprintf("repos/%s/%s/contents/%s?ref=%s", owner, repo, parentPath, commitSHA) + apiPath := fmt.Sprintf("repos/%s/%s/contents/%s?ref=%s", url.PathEscape(owner), url.PathEscape(repo), parentPath, commitSHA) var contents []struct { Name string `json:"name"` @@ -489,7 +514,7 @@ func DiscoverSkillByPath(client *api.Client, host, owner, repo, commitSHA, skill return nil, fmt.Errorf("skill directory %q not found in %s/%s", skillPath, owner, repo) } - skillTreePath := fmt.Sprintf("repos/%s/%s/git/trees/%s", owner, repo, treeSHA) + 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) @@ -528,15 +553,15 @@ func DiscoverSkillByPath(client *api.Client, host, owner, repo, commitSHA, skill // 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", owner, repo, treeSHA) + 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) + // Recursive fetch was truncated. Fall back to walking subtrees individually. + return walkTree(client, host, owner, repo, treeSHA, skillPath, 0) } var files []SkillFile @@ -556,7 +581,7 @@ func DiscoverSkillFiles(client *api.Client, host, owner, repo, treeSHA, skillPat // 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", owner, repo, treeSHA) + 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) @@ -564,7 +589,7 @@ func ListSkillFiles(client *api.Client, host, owner, repo, treeSHA string) ([]Sk if tree.Truncated { // Fall back to non-recursive traversal when the tree is too large. - return walkTree(client, host, owner, repo, treeSHA, "") + return walkTree(client, host, owner, repo, treeSHA, "", 0) } var files []SkillFile @@ -580,10 +605,18 @@ func ListSkillFiles(client *api.Client, host, owner, repo, treeSHA string) ([]Sk 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. -func walkTree(client *api.Client, host, owner, repo, sha, prefix string) ([]SkillFile, error) { - apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s", owner, repo, sha) +// 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) @@ -599,7 +632,7 @@ func walkTree(client *api.Client, host, owner, repo, sha, prefix string) ([]Skil 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) + sub, err := walkTree(client, host, owner, repo, entry.SHA, entryPath, depth+1) if err != nil { return nil, err } @@ -611,7 +644,7 @@ func walkTree(client *api.Client, host, owner, repo, sha, prefix string) ([]Skil // 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", owner, repo, sha) + apiPath := fmt.Sprintf("repos/%s/%s/git/blobs/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha)) var blob blobResponse if err := client.REST(host, "GET", apiPath, nil, &blob); err != nil { return "", fmt.Errorf("could not fetch blob: %w", err) diff --git a/internal/skills/discovery/discovery_test.go b/internal/skills/discovery/discovery_test.go index 3bc719ae8..2de7ef683 100644 --- a/internal/skills/discovery/discovery_test.go +++ b/internal/skills/discovery/discovery_test.go @@ -438,7 +438,7 @@ func TestResolveRef(t *testing.T) { wantSHA: "fallback-sha", }, { - name: "empty default_branch falls back to main", + name: "empty default_branch returns error", stubs: func(reg *httpmock.Registry) { reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), @@ -446,14 +446,41 @@ func TestResolveRef(t *testing.T) { reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills"), httpmock.JSONResponse(map[string]interface{}{"default_branch": ""})) + }, + wantErr: "could not determine default branch", + }, + { + name: "short name with server error on branch lookup does not fall through", + version: "main", + stubs: func(reg *httpmock.Registry) { reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/main"), - httpmock.JSONResponse(map[string]interface{}{ - "object": map[string]interface{}{"sha": "main-sha"}, - })) + httpmock.StatusStringResponse(500, "server error")) }, - wantRef: "refs/heads/main", - wantSHA: "main-sha", + wantErr: `branch "main" not found in monalisa/octocat-skills`, + }, + { + name: "short name with forbidden error on branch lookup does not fall through", + version: "develop", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/develop"), + httpmock.StatusStringResponse(403, "forbidden")) + }, + wantErr: `branch "develop" not found in monalisa/octocat-skills`, + }, + { + name: "short name with server error on tag lookup does not fall through", + version: "v5.0", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/v5.0"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v5.0"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantErr: `tag "v5.0" not found in monalisa/octocat-skills`, }, } for _, tt := range tests { diff --git a/internal/skills/installer/installer.go b/internal/skills/installer/installer.go index 5fdf99ce0..0ac9e182c 100644 --- a/internal/skills/installer/installer.go +++ b/internal/skills/installer/installer.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" "sync" + "sync/atomic" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/git" @@ -86,31 +87,34 @@ func Install(opts *Options) (*Result, error) { opts.OnProgress(0, total) } - sem := make(chan struct{}, maxConcurrency) + type job struct { + idx int + skill discovery.Skill + } + jobs := make(chan job) + results := make([]skillResult, total) var wg sync.WaitGroup - var mu sync.Mutex - done := 0 + var done atomic.Int32 - for i, skill := range opts.Skills { - wg.Add(1) - go func(idx int, s discovery.Skill) { - defer wg.Done() - sem <- struct{}{} - defer func() { <-sem }() + workers := min(maxConcurrency, total) + for range workers { + wg.Go(func() { + for j := range jobs { + err := installSkill(opts, j.skill, targetDir) + results[j.idx] = skillResult{name: j.skill.InstallName(), err: err} - err := installSkill(opts, s, targetDir) - results[idx] = skillResult{name: s.InstallName(), err: err} - - if opts.OnProgress != nil { - mu.Lock() - done++ - d := done - mu.Unlock() - opts.OnProgress(d, total) + if opts.OnProgress != nil { + opts.OnProgress(int(done.Add(1)), total) + } } - }(i, skill) + }) } + + for i, s := range opts.Skills { + jobs <- job{idx: i, skill: s} + } + close(jobs) wg.Wait() var installed []string diff --git a/internal/skills/installer/installer_test.go b/internal/skills/installer/installer_test.go index 85c8bcf18..6334add85 100644 --- a/internal/skills/installer/installer_test.go +++ b/internal/skills/installer/installer_test.go @@ -134,7 +134,7 @@ func TestInstallLocal(t *testing.T) { }, verify: func(t *testing.T, destDir string) { t.Helper() - _, err := os.Stat(filepath.Join(destDir, ".github", "skills", "code-review", "SKILL.md")) + _, err := os.Stat(filepath.Join(destDir, ".agents", "skills", "code-review", "SKILL.md")) assert.NoError(t, err) }, }, diff --git a/internal/skills/lockfile/lockfile.go b/internal/skills/lockfile/lockfile.go index 3a6ccd893..42d2abb34 100644 --- a/internal/skills/lockfile/lockfile.go +++ b/internal/skills/lockfile/lockfile.go @@ -2,10 +2,14 @@ package lockfile import ( "encoding/json" + "errors" "fmt" + "io" "os" "path/filepath" "time" + + "github.com/cli/cli/v2/internal/flock" ) const ( @@ -43,61 +47,68 @@ func lockfilePath() (string, error) { return filepath.Join(home, agentsDir, lockFile), nil } -// read loads the lock file, returning an empty file if it doesn't exist -// or if it's an incompatible version. -func read() (*file, error) { - lockPath, err := lockfilePath() - if err != nil { - return newFile(), nil //nolint:nilerr // graceful: no home dir means fresh state +// readFrom loads the lock file from an open file handle. +// Returns an empty file if the content is empty, corrupt, or incompatible. +func readFrom(f *os.File) (*file, error) { + if _, err := f.Seek(0, 0); err != nil { + return nil, fmt.Errorf("could not seek lock file: %w", err) } - - data, err := os.ReadFile(lockPath) + data, err := io.ReadAll(f) if err != nil { - if os.IsNotExist(err) { - return newFile(), nil - } return nil, fmt.Errorf("could not read lock file: %w", err) } - - var f file - if err := json.Unmarshal(data, &f); err != nil { - return newFile(), nil //nolint:nilerr // graceful: corrupt file means fresh state - } - - if f.Version != lockVersion || f.Skills == nil { + if len(data) == 0 { return newFile(), nil } - return &f, nil + var lf file + if err := json.Unmarshal(data, &lf); err != nil { + return newFile(), nil //nolint:nilerr // graceful: corrupt file means fresh state + } + + if lf.Version != lockVersion || lf.Skills == nil { + return newFile(), nil + } + + return &lf, nil } -// write persists the lock file to disk. -func write(f *file) error { - lockPath, err := lockfilePath() +// writeTo persists the lock file through an open file handle. +func writeTo(f *os.File, lf *file) error { + data, err := json.MarshalIndent(lf, "", " ") if err != nil { return err } - if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { + if _, err := f.Seek(0, 0); err != nil { return err } - - data, err := json.MarshalIndent(f, "", " ") - if err != nil { + if err := f.Truncate(0); err != nil { return err } - - return os.WriteFile(lockPath, data, 0o644) + _, err = f.Write(data) + return err } // RecordInstall adds or updates a skill entry in the lock file. // It uses a file-based lock to prevent concurrent read-modify-write races // when multiple install processes run simultaneously. func RecordInstall(skillName, owner, repo, skillPath, treeSHA, pinnedRef string) error { - unlock := acquireLock() + lockPath, err := lockfilePath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { + return fmt.Errorf("could not create lock directory: %w", err) + } + + lockedFile, unlock, err := acquireFLock() + if err != nil { + return err + } defer unlock() - f, err := read() + f, err := readFrom(lockedFile) if err != nil { return err } @@ -121,7 +132,7 @@ func RecordInstall(skillName, owner, repo, skillPath, treeSHA, pinnedRef string) PinnedRef: pinnedRef, } - return write(f) + return writeTo(lockedFile, f) } func newFile() *file { @@ -132,44 +143,35 @@ func newFile() *file { } var ( - lockRetries = 30 - lockRetryInterval = 100 * time.Millisecond + lockAttempts = 30 + lockAttemptDelay = 100 * time.Millisecond ) -// acquireLock creates an exclusive lock file to serialize concurrent access. -// Returns an unlock function. If locking fails after retries, it proceeds -// unlocked rather than blocking the user indefinitely. -func acquireLock() (unlock func()) { - lockPath, pathErr := lockfilePath() - if pathErr != nil { - return func() {} - } - lkPath := lockPath + ".lk" - - // Ensure the parent directory exists (fresh machine may lack ~/.agents). - if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { - return func() {} +// acquireFLock attempts to acquire an exclusive file lock to serialize concurrent access. +// Returns the locked file handle and an unlock function, or an error if the lock +// cannot be acquired. The caller should read/write through the returned file to +// avoid Windows mandatory lock conflicts. +func acquireFLock() (f *os.File, unlock func(), err error) { + lockPath, err := lockfilePath() + if err != nil { + return nil, nil, fmt.Errorf("could not determine lock path: %w", err) } - for range lockRetries { - f, createErr := os.OpenFile(lkPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) - if createErr == nil { - f.Close() - return func() { os.Remove(lkPath) } + var lastErr error + for attempt := range lockAttempts { + f, unlock, err := flock.TryLock(lockPath) + if err == nil { + return f, unlock, nil } - // Only retry when the lock file already exists (concurrent process). - // For other errors (permission denied, invalid path, etc.) give up immediately. - if !os.IsExist(createErr) { - return func() {} + lastErr = err + + if !errors.Is(err, flock.ErrLocked) { + return nil, nil, err } - // Break stale locks older than 30s (e.g. from a crashed process). - if info, statErr := os.Stat(lkPath); statErr == nil && time.Since(info.ModTime()) > 30*time.Second { - os.Remove(lkPath) - continue + if attempt < lockAttempts-1 { + time.Sleep(lockAttemptDelay) } - time.Sleep(lockRetryInterval) } - // Best-effort: proceed without lock. - return func() {} + return nil, nil, fmt.Errorf("could not acquire lock after %d attempts: %w", lockAttempts, lastErr) } diff --git a/internal/skills/lockfile/lockfile_test.go b/internal/skills/lockfile/lockfile_test.go index d4a44f76d..d68e9a8f1 100644 --- a/internal/skills/lockfile/lockfile_test.go +++ b/internal/skills/lockfile/lockfile_test.go @@ -5,8 +5,8 @@ import ( "os" "path/filepath" "testing" - "time" + "github.com/cli/cli/v2/internal/flock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -23,13 +23,14 @@ func setupTestHome(t *testing.T) string { func TestRecordInstall(t *testing.T) { tests := []struct { name string - setup func(t *testing.T) // optional pre-existing state + setup func(t *testing.T) skill string owner string repo string skillPath string treeSHA string pinnedRef string + wantErr bool verify func(t *testing.T, lockPath string) }{ { @@ -87,63 +88,31 @@ func TestRecordInstall(t *testing.T) { }, }, { - name: "succeeds despite stale lock file", + name: "returns error when lock cannot be acquired", setup: func(t *testing.T) { t.Helper() - lockPath, err := lockfilePath() - require.NoError(t, err) - require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755)) - lkPath := lockPath + ".lk" - f, err := os.Create(lkPath) - require.NoError(t, err) - f.Close() - staleTime := time.Now().Add(-60 * time.Second) - require.NoError(t, os.Chtimes(lkPath, staleTime, staleTime)) - }, - skill: "code-review", - owner: "monalisa", - repo: "octocat-skills", - skillPath: "skills/code-review/SKILL.md", - treeSHA: "abc123", - verify: func(t *testing.T, lockPath string) { - t.Helper() - f := readTestLockfile(t, lockPath) - require.Contains(t, f.Skills, "code-review") - _, err := os.Stat(lockPath + ".lk") - assert.True(t, os.IsNotExist(err), "stale lock should be removed after RecordInstall") - }, - }, - { - name: "proceeds without lock after retries exhausted", - setup: func(t *testing.T) { - t.Helper() - // Reduce retries to avoid 3s wait in tests. - origRetries := lockRetries - origInterval := lockRetryInterval - lockRetries = 1 - lockRetryInterval = 0 + origAttempts := lockAttempts + origDelay := lockAttemptDelay + lockAttempts = 1 + lockAttemptDelay = 0 t.Cleanup(func() { - lockRetries = origRetries - lockRetryInterval = origInterval + lockAttempts = origAttempts + lockAttemptDelay = origDelay }) - // Create a fresh (non-stale) lock file that won't be broken. + // Hold a real flock so acquireFLock fails. lockPath, err := lockfilePath() require.NoError(t, err) require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755)) - f, err := os.Create(lockPath + ".lk") + _, unlock, err := flock.TryLock(lockPath) require.NoError(t, err) - f.Close() + t.Cleanup(unlock) }, skill: "code-review", owner: "monalisa", repo: "octocat-skills", skillPath: "skills/code-review/SKILL.md", treeSHA: "abc123", - verify: func(t *testing.T, lockPath string) { - t.Helper() - f := readTestLockfile(t, lockPath) - require.Contains(t, f.Skills, "code-review", "should succeed best-effort without lock") - }, + wantErr: true, }, { name: "recovers from corrupt lockfile", @@ -198,6 +167,10 @@ func TestRecordInstall(t *testing.T) { } err := RecordInstall(tt.skill, tt.owner, tt.repo, tt.skillPath, tt.treeSHA, tt.pinnedRef) + if tt.wantErr { + require.Error(t, err) + return + } require.NoError(t, err) tt.verify(t, lockPath) }) diff --git a/internal/skills/registry/registry.go b/internal/skills/registry/registry.go index a8fdc5993..b112d361a 100644 --- a/internal/skills/registry/registry.go +++ b/internal/skills/registry/registry.go @@ -28,6 +28,8 @@ const ( ScopeProject Scope = "project" ScopeUser Scope = "user" + DefaultAgentID = "github-copilot" + sharedProjectSkillsDir = ".agents/skills" ) @@ -144,13 +146,13 @@ func (h *AgentHost) InstallDir(scope Scope, gitRoot, homeDir string) (string, er // If repoName is non-empty, it is included in the project-scope label // for additional context. func ScopeLabels(repoName string) []string { - projectLabel := "Project — install in current repository (recommended)" + projectLabel := "Project: install in current repository (recommended)" if repoName != "" { - projectLabel = fmt.Sprintf("Project — %s (recommended)", repoName) + projectLabel = fmt.Sprintf("Project: %s (recommended)", repoName) } return []string{ projectLabel, - "Global — install in home directory (available everywhere)", + "Global: install in home directory (available everywhere)", } } diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 262af1b78..d44ad840c 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -145,6 +145,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command, cmd.AddCommand(codespaceCmd.NewCmdCodespace(f)) cmd.AddCommand(projectCmd.NewCmdProject(f)) cmd.AddCommand(previewCmd.NewCmdPreview(f)) + cmd.AddCommand(skillsCmd.NewCmdSkills(f)) // Root commands with standalone functionality and no subcommands cmd.AddCommand(copilotCmd.NewCmdCopilot(f, nil)) @@ -165,7 +166,6 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command, cmd.AddCommand(repoCmd.NewCmdRepo(&repoResolvingCmdFactory)) cmd.AddCommand(rulesetCmd.NewCmdRuleset(&repoResolvingCmdFactory)) cmd.AddCommand(runCmd.NewCmdRun(&repoResolvingCmdFactory)) - cmd.AddCommand(skillsCmd.NewCmdSkills(f)) cmd.AddCommand(workflowCmd.NewCmdWorkflow(&repoResolvingCmdFactory)) cmd.AddCommand(labelCmd.NewCmdLabel(&repoResolvingCmdFactory)) cmd.AddCommand(cacheCmd.NewCmdCache(&repoResolvingCmdFactory)) diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index fc65e2f0c..fce53583d 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -7,7 +7,6 @@ import ( "net/http" "os" "path/filepath" - "sort" "strings" "github.com/MakeNowJust/heredoc" @@ -36,35 +35,32 @@ const ( maxSearchResults = 30 ) -// installOptions holds all dependencies and user-provided flags for the install command. -type installOptions struct { +// InstallOptions holds all dependencies and user-provided flags for the install command. +type InstallOptions struct { IO *iostreams.IOStreams HttpClient func() (*http.Client, error) Prompter prompter.Prompter GitClient *git.Client Remotes func() (ghContext.Remotes, error) - // Arguments - SkillSource string // owner/repo or local path - SkillName string // skill name, possibly with @version + SkillSource string // owner/repo or local path (when --from-local is set) + SkillName string // possibly with @version suffix + Agent string + Scope string + ScopeChanged bool // true when --scope was explicitly set + Pin string + Dir string // overrides --agent and --scope + Force bool + FromLocal bool // treat SkillSource as a local directory path - // Flags - Agent string // --agent flag - Scope string // --scope flag - ScopeChanged bool // true when --scope was explicitly set - Pin string // --pin flag - Dir string // --dir flag (overrides host+scope) - Force bool // --force flag - - // Resolved at runtime repo ghrepo.Interface // set when SkillSource is a GitHub repository - localPath string // set when SkillSource is a local directory - version string + localPath string // set when FromLocal is true + version string // parsed from SkillName@version } // NewCmdInstall creates the "skills install" command. -func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra.Command { - opts := &installOptions{ +func NewCmdInstall(f *cmdutil.Factory, runF func(*InstallOptions) error) *cobra.Command { + opts := &InstallOptions{ IO: f.IOStreams, Prompter: f.Prompter, GitClient: f.GitClient, @@ -73,21 +69,21 @@ func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra. } cmd := &cobra.Command{ - Use: "install []", - Short: "Install agent skills from a GitHub repository", + Use: "install [] [flags]", + Short: "Install agent skills from a GitHub repository (preview)", Long: heredoc.Docf(` Install agent skills from a GitHub repository or local directory into your local environment. Skills are placed in a host-specific directory at either project scope (inside the current git repository) or user - scope (in your home directory, available everywhere): + scope (in your home directory, available everywhere). Supported hosts + and their storage directories are (project, user): - Host Project User - GitHub Copilot .agents/skills ~/.copilot/skills - Claude Code .claude/skills ~/.claude/skills - Cursor .agents/skills ~/.cursor/skills - Codex .agents/skills ~/.codex/skills - Gemini CLI .agents/skills ~/.gemini/skills - Antigravity .agents/skills ~/.gemini/antigravity/skills + - GitHub Copilot (%[1]s.agents/skills%[1]s, %[1]s~/.copilot/skills%[1]s) + - Claude Code (%[1]s.claude/skills%[1]s, %[1]s~/.claude/skills%[1]s) + - Cursor (%[1]s.agents/skills%[1]s, %[1]s~/.cursor/skills%[1]s) + - Codex (%[1]s.agents/skills%[1]s, %[1]s~/.codex/skills%[1]s) + - Gemini CLI (%[1]s.agents/skills%[1]s, %[1]s~/.gemini/skills%[1]s) + - Antigravity (%[1]s.agents/skills%[1]s, %[1]s~/.gemini/antigravity/skills%[1]s) Use %[1]s--agent%[1]s and %[1]s--scope%[1]s to control placement, or %[1]s--dir%[1]s for a custom directory. The default scope is %[1]sproject%[1]s, and the default @@ -98,11 +94,11 @@ func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra. select multiple hosts that resolve to the same destination, each skill is installed there only once. - The first argument can be a GitHub repository in %[1]sOWNER/REPO%[1]s format - or a local directory path (e.g. %[1]s.%[1]s, %[1]s./my-skills%[1]s, %[1]s~/skills%[1]s). - For local directories, skills are auto-discovered using the same - conventions as remote repositories, and files are copied (not symlinked) - with local-path tracking metadata injected into frontmatter. + The first argument is a GitHub repository in %[1]sOWNER/REPO%[1]s format. + Use %[1]s--from-local%[1]s to install from a local directory instead. + Local skills are auto-discovered using the same conventions as remote + repositories, and files are copied (not symlinked) with local-path + tracking metadata injected into frontmatter. Skills are discovered automatically using the %[1]sskills/*/SKILL.md%[1]s convention defined by the Agent Skills specification. For more information on the specification, @@ -125,12 +121,9 @@ func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra. To pin to a specific version, either append %[1]s@VERSION%[1]s to the skill name or use the %[1]s--pin%[1]s flag. The version is resolved as a git tag or commit SHA. - Installed skills have GitHub tracking metadata injected into their - frontmatter (%[1]sgithub-repo%[1]s, %[1]sgithub-ref%[1]s, - %[1]sgithub-tree-sha%[1]s, %[1]sgithub-path%[1]s). This - metadata identifies the source repository and enables %[1]sgh skill update%[1]s - to detect changes — the tree SHA serves as an ETag for staleness checks. - The %[1]sgithub-repo%[1]s value is stored as a full repository URL. + Installed skills have source tracking metadata injected into their + frontmatter. This metadata identifies the source repository and + enables %[1]sgh skill update%[1]s to detect changes. When run interactively, the command prompts for any missing arguments. When run non-interactively, %[1]srepository%[1]s and a skill name are @@ -152,14 +145,11 @@ func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra. # Install from a large namespaced repo by path (efficient, skips full discovery) $ gh skill install github/awesome-copilot skills/monalisa/code-review - # Install from a local directory (auto-discovers skills) - $ gh skill install ./my-skills-repo + # Install from a local directory + $ gh skill install ./my-skills-repo --from-local - # Install from current directory - $ gh skill install . - - # Install a single local skill directory - $ gh skill install ./skills/git-commit + # Install a specific local skill + $ gh skill install ./my-skills-repo git-commit --from-local # Install for Claude Code at user scope $ gh skill install github/awesome-copilot git-commit --agent claude-code --scope user @@ -170,9 +160,6 @@ func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra. Aliases: []string{"add"}, Args: cobra.MaximumNArgs(2), RunE: func(cmd *cobra.Command, args []string) error { - if len(args) == 0 && !opts.IO.CanPrompt() { - return cmdutil.FlagErrorf("must specify a repository to install from") - } if len(args) >= 1 { opts.SkillSource = args[0] } @@ -182,14 +169,17 @@ func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra. opts.ScopeChanged = cmd.Flags().Changed("scope") // Resolve the source type early so installRun can branch directly. - if isLocalPath(opts.SkillSource) { + if opts.FromLocal { + if opts.SkillSource == "" { + return cmdutil.FlagErrorf("--from-local requires a directory path argument") + } opts.localPath = opts.SkillSource + } else if len(args) == 0 && !opts.IO.CanPrompt() { + return cmdutil.FlagErrorf("must specify a repository to install from") } - if opts.Agent != "" { - if _, err := registry.FindByID(opts.Agent); err != nil { - return cmdutil.FlagErrorf("invalid value for --agent: %s", err) - } + if err := cmdutil.MutuallyExclusive("--from-local and --pin cannot be used together", opts.FromLocal, opts.Pin != ""); err != nil { + return err } if opts.Pin != "" && opts.SkillName != "" && strings.Contains(opts.SkillName, "@") { @@ -203,19 +193,17 @@ func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra. }, } - cmd.Flags().StringVar(&opts.Agent, "agent", "", fmt.Sprintf("target agent (%s)", registry.ValidAgentIDs())) - _ = cmd.RegisterFlagCompletionFunc("agent", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { - return registry.AgentIDs(), cobra.ShellCompDirectiveNoFileComp - }) + cmdutil.StringEnumFlag(cmd, &opts.Agent, "agent", "", "", registry.AgentIDs(), "Target agent") cmdutil.StringEnumFlag(cmd, &opts.Scope, "scope", "", "project", []string{"project", "user"}, "Installation scope") - cmd.Flags().StringVar(&opts.Pin, "pin", "", "pin to a specific git tag or commit SHA") - cmd.Flags().StringVar(&opts.Dir, "dir", "", "install to a custom directory (overrides --agent and --scope)") - cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "overwrite existing skills without prompting") + cmd.Flags().StringVar(&opts.Pin, "pin", "", "Pin to a specific git tag or commit SHA") + cmd.Flags().StringVar(&opts.Dir, "dir", "", "Install to a custom directory (overrides --agent and --scope)") + cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Overwrite existing skills without prompting") + cmd.Flags().BoolVar(&opts.FromLocal, "from-local", false, "Treat the argument as a local directory path instead of a repository") return cmd } -func installRun(opts *installOptions) error { +func installRun(opts *InstallOptions) error { cs := opts.IO.ColorScheme() canPrompt := opts.IO.CanPrompt() @@ -278,6 +266,8 @@ func installRun(opts *installOptions) error { } } + printPreInstallDisclaimer(opts.IO.ErrOut, cs) + selectedHosts, err := resolveHosts(opts, canPrompt) if err != nil { return err @@ -325,7 +315,7 @@ func installRun(opts *installOptions) error { cs.SuccessIcon(), name, repoSource, discovery.ShortRef(resolved.Ref), friendlyDir(result.Dir)) } - printFileTree(opts.IO.Out, cs, result.Dir, result.Installed) + printFileTree(opts.IO.ErrOut, cs, result.Dir, result.Installed) printReviewHint(opts.IO.ErrOut, cs, repoSource, resolved.SHA, result.Installed) } @@ -337,33 +327,8 @@ func installRun(opts *installOptions) error { return nil } -// isLocalPath returns true if the argument looks like a local filesystem path -// rather than a GitHub owner/repo reference. -func isLocalPath(arg string) bool { - if arg == "" { - return false - } - sep := string(filepath.Separator) - if arg == "." || arg == ".." || - strings.HasPrefix(arg, "./") || strings.HasPrefix(arg, "../") || - strings.HasPrefix(arg, "."+sep) || strings.HasPrefix(arg, ".."+sep) { - return true - } - // filepath.IsAbs on Windows requires a drive letter, so "/tmp/foo" - // would not be recognized. Check explicitly for a leading "/" so that - // Unix-style absolute paths are never mistaken for owner/repo refs. - if filepath.IsAbs(arg) || arg[0] == '/' || strings.HasPrefix(arg, "~") { - return true - } - info, err := os.Stat(arg) - if err == nil && info.IsDir() { - return true - } - return false -} - // runLocalInstall handles installation from a local directory path. -func runLocalInstall(opts *installOptions) error { +func runLocalInstall(opts *InstallOptions) error { cs := opts.IO.ColorScheme() canPrompt := opts.IO.CanPrompt() sourcePath := opts.localPath @@ -401,6 +366,8 @@ func runLocalInstall(opts *installOptions) error { return err } + printPreInstallDisclaimer(opts.IO.ErrOut, cs) + selectedHosts, err := resolveHosts(opts, canPrompt) if err != nil { return err @@ -438,7 +405,7 @@ func runLocalInstall(opts *installOptions) error { name, opts.SkillSource, friendlyDir(result.Dir)) } - printFileTree(opts.IO.Out, cs, result.Dir, result.Installed) + printFileTree(opts.IO.ErrOut, cs, result.Dir, result.Installed) printReviewHint(opts.IO.ErrOut, cs, "", "", result.Installed) } @@ -481,7 +448,7 @@ func resolveRepoArg(skillSource string, canPrompt bool, p prompter.Prompter) (gh return repo, skillSource, nil } -func parseSkillFromOpts(opts *installOptions) { +func parseSkillFromOpts(opts *InstallOptions) { if opts.SkillName != "" { if name, version, ok := cutLast(opts.SkillName, "@"); ok && name != "" { opts.version = version @@ -503,7 +470,7 @@ func cutLast(s, sep string) (before, after string, found bool) { return s, "", false } -func resolveVersion(opts *installOptions, client *api.Client, hostname string) (*discovery.ResolvedRef, error) { +func resolveVersion(opts *InstallOptions, client *api.Client, hostname string) (*discovery.ResolvedRef, error) { opts.IO.StartProgressIndicatorWithLabel("Resolving version") resolved, err := discovery.ResolveRef(client, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), opts.version) opts.IO.StopProgressIndicator() @@ -514,11 +481,17 @@ func resolveVersion(opts *installOptions, client *api.Client, hostname string) ( return resolved, nil } -func discoverSkills(opts *installOptions, client *api.Client, hostname string, resolved *discovery.ResolvedRef) ([]discovery.Skill, error) { +func discoverSkills(opts *InstallOptions, client *api.Client, hostname string, resolved *discovery.ResolvedRef) ([]discovery.Skill, error) { opts.IO.StartProgressIndicatorWithLabel("Discovering skills") skills, err := discovery.DiscoverSkills(client, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), resolved.SHA) opts.IO.StopProgressIndicator() if err != nil { + var treeTooLarge *discovery.TreeTooLargeError + if errors.As(err, &treeTooLarge) { + fmt.Fprintf(opts.IO.ErrOut, "%s\n Use path-based install instead: gh skill install %s/%s skills/\n", + err, treeTooLarge.Owner, treeTooLarge.Repo) + return nil, err + } return nil, err } logConventions(opts.IO, skills) @@ -527,9 +500,6 @@ func discoverSkills(opts *installOptions, client *api.Client, hostname string, r fmt.Fprintf(opts.IO.ErrOut, "Warning: skill %q does not follow the agentskills.io naming convention\n", s.DisplayName()) } } - sort.Slice(skills, func(i, j int) bool { - return skills[i].DisplayName() < skills[j].DisplayName() - }) return skills, nil } @@ -552,7 +522,7 @@ func logConventions(io *iostreams.IOStreams, skills []discovery.Skill) { // skillSelector holds the callbacks that differ between remote and local skill selection. type skillSelector struct { // matchByName resolves a skill name to matching skills. - matchByName func(opts *installOptions, skills []discovery.Skill) ([]discovery.Skill, error) + matchByName func(opts *InstallOptions, skills []discovery.Skill) ([]discovery.Skill, error) // sourceHint is shown in collision error guidance (e.g. "owner/repo" or "/path/to/skills"). sourceHint string // fetchDescriptions, if non-nil, is called before prompting to pre-populate descriptions. @@ -565,9 +535,13 @@ type installPlan struct { skills []discovery.Skill } -func selectSkillsWithSelector(opts *installOptions, skills []discovery.Skill, canPrompt bool, sel skillSelector) ([]discovery.Skill, error) { +func selectSkillsWithSelector(opts *InstallOptions, skills []discovery.Skill, canPrompt bool, sel skillSelector) ([]discovery.Skill, error) { checkCollisions := func(ss []discovery.Skill) error { - return collisionError(ss, sel.sourceHint) + if err := collisionError(ss); err != nil { + fmt.Fprintf(opts.IO.ErrOut, "Hint: install individually using the full name: gh skill install %s namespace/skill-name\n", sel.sourceHint) + return err + } + return nil } if opts.SkillName != "" { @@ -619,7 +593,7 @@ func selectSkillsWithSelector(opts *installOptions, skills []discovery.Skill, ca return result, checkCollisions(result) } -func matchSkillByName(opts *installOptions, skills []discovery.Skill) ([]discovery.Skill, error) { +func matchSkillByName(opts *InstallOptions, skills []discovery.Skill) ([]discovery.Skill, error) { for _, s := range skills { if s.DisplayName() == opts.SkillName { return []discovery.Skill{s}, nil @@ -644,13 +618,13 @@ func matchSkillByName(opts *installOptions, skills []discovery.Skill) ([]discove names[i] = m.DisplayName() } return nil, fmt.Errorf( - "skill name %q is ambiguous — multiple matches found:\n %s\n Specify the full name (e.g. %s) to disambiguate", + "skill name %q is ambiguous, multiple matches found:\n %s\n Specify the full name (e.g. %s) to disambiguate", opts.SkillName, strings.Join(names, "\n "), names[0], ) } } -func matchLocalSkillByName(opts *installOptions, skills []discovery.Skill) ([]discovery.Skill, error) { +func matchLocalSkillByName(opts *InstallOptions, skills []discovery.Skill) ([]discovery.Skill, error) { for _, s := range skills { if s.DisplayName() == opts.SkillName || s.Name == opts.SkillName { return []discovery.Skill{s}, nil @@ -687,7 +661,7 @@ func skillSearchFunc(skills []discovery.Skill, descWidth int) func(string) promp for i, s := range matched { keys[i] = s.DisplayName() if s.Description != "" { - labels[i] = fmt.Sprintf("%s — %s", s.DisplayName(), truncateDescription(s.Description, descWidth)) + labels[i] = fmt.Sprintf("%s - %s", s.DisplayName(), truncateDescription(s.Description, descWidth)) } else { labels[i] = s.DisplayName() } @@ -720,22 +694,17 @@ func matchSelectedSkills(skills []discovery.Skill, selected []string) ([]discove return result, nil } -// collisionError checks for name collisions and returns an error with -// guidance on how to install skills individually. -func collisionError(ss []discovery.Skill, sourceHint string) error { +// collisionError checks for name collisions among the selected skills. +func collisionError(ss []discovery.Skill) error { collisions := discovery.FindNameCollisions(ss) if len(collisions) == 0 { return nil } - return errors.New(heredoc.Docf(` - cannot install skills with conflicting names — they would overwrite each other: - %s - Install these skills individually using the full name: - gh skill install %s namespace/skill-name - `, discovery.FormatCollisions(collisions), sourceHint)) + return fmt.Errorf("cannot install skills with conflicting names; they would overwrite each other:\n %s", + discovery.FormatCollisions(collisions)) } -func resolveHosts(opts *installOptions, canPrompt bool) ([]*registry.AgentHost, error) { +func resolveHosts(opts *InstallOptions, canPrompt bool) ([]*registry.AgentHost, error) { if opts.Agent != "" { h, err := registry.FindByID(opts.Agent) if err != nil { @@ -745,7 +714,7 @@ func resolveHosts(opts *installOptions, canPrompt bool) ([]*registry.AgentHost, } if !canPrompt { - h, err := registry.FindByID("github-copilot") + h, err := registry.FindByID(registry.DefaultAgentID) if err != nil { return nil, err } @@ -770,7 +739,7 @@ func resolveHosts(opts *installOptions, canPrompt bool) ([]*registry.AgentHost, return selected, nil } -func resolveScope(opts *installOptions, canPrompt bool) (registry.Scope, error) { +func resolveScope(opts *InstallOptions, canPrompt bool) (registry.Scope, error) { if opts.Dir != "" { return registry.Scope(opts.Scope), nil } @@ -795,7 +764,7 @@ func resolveScope(opts *installOptions, canPrompt bool) (registry.Scope, error) return registry.ScopeUser, nil } -func buildInstallPlans(opts *installOptions, selectedSkills []discovery.Skill, selectedHosts []*registry.AgentHost, scope registry.Scope, gitRoot, homeDir string, canPrompt bool) ([]installPlan, error) { +func buildInstallPlans(opts *InstallOptions, selectedSkills []discovery.Skill, selectedHosts []*registry.AgentHost, scope registry.Scope, gitRoot, homeDir string, canPrompt bool) ([]installPlan, error) { byDir := make(map[string]*installPlan) orderedDirs := make([]string, 0, len(selectedHosts)) @@ -832,7 +801,7 @@ func buildInstallPlans(opts *installOptions, selectedSkills []discovery.Skill, s return plans, nil } -func resolveInstallDir(opts *installOptions, host *registry.AgentHost, scope registry.Scope, gitRoot, homeDir string) (string, error) { +func resolveInstallDir(opts *InstallOptions, host *registry.AgentHost, scope registry.Scope, gitRoot, homeDir string) (string, error) { if opts.Dir != "" { return opts.Dir, nil } @@ -851,7 +820,7 @@ func truncateDescription(s string, maxWidth int) string { return text.Truncate(maxWidth, text.RemoveExcessiveWhitespace(s)) } -func checkOverwrite(opts *installOptions, skills []discovery.Skill, targetDir string, canPrompt bool) ([]discovery.Skill, error) { +func checkOverwrite(opts *InstallOptions, skills []discovery.Skill, targetDir string, canPrompt bool) ([]discovery.Skill, error) { var existing, fresh []discovery.Skill for _, s := range skills { dir := filepath.Join(targetDir, filepath.FromSlash(s.InstallName())) @@ -948,8 +917,10 @@ func friendlyDir(dir string) string { return rel } } - if home, err := os.UserHomeDir(); err == nil && (dir == home || strings.HasPrefix(dir, home+string(filepath.Separator))) { - return "~" + dir[len(home):] + if home, err := os.UserHomeDir(); err == nil { + if rel, err := filepath.Rel(home, dir); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "~/" + rel + } } return dir } @@ -991,6 +962,12 @@ func printTreeDir(w io.Writer, cs *iostreams.ColorScheme, dir, indent string) { } } +// printPreInstallDisclaimer prints a warning that installed skills are unverified +// and should be inspected before use. +func printPreInstallDisclaimer(w io.Writer, cs *iostreams.ColorScheme) { + fmt.Fprintf(w, "\n%s Skills are not verified by GitHub and may contain prompt injections, hidden instructions, or malicious scripts. Always review skill contents before use.\n\n", cs.WarningIcon()) +} + // printReviewHint warns the user to review installed skills and suggests preview commands. // When sha is non-empty the suggested commands include @SHA so the user previews // exactly the version that was installed. diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index b15f5a9b2..481227524 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -15,6 +15,7 @@ import ( "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/skills/discovery" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" @@ -27,24 +28,24 @@ func TestNewCmdInstall(t *testing.T) { tests := []struct { name string cli string - wantOpts installOptions + wantOpts InstallOptions wantLocalPath bool wantErr bool }{ { name: "repo argument only", cli: "monalisa/skills-repo", - wantOpts: installOptions{SkillSource: "monalisa/skills-repo", Scope: "project"}, + wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", Scope: "project"}, }, { name: "repo and skill", cli: "monalisa/skills-repo git-commit", - wantOpts: installOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Scope: "project"}, + wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Scope: "project"}, }, { name: "all flags", cli: "monalisa/skills-repo git-commit --agent github-copilot --scope user --pin v1.0.0 --force", - wantOpts: installOptions{ + wantOpts: InstallOptions{ SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Agent: "github-copilot", @@ -56,7 +57,7 @@ func TestNewCmdInstall(t *testing.T) { { name: "dir flag", cli: "monalisa/skills-repo git-commit --dir ./custom-skills", - wantOpts: installOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Dir: "./custom-skills", Scope: "project"}, + wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Dir: "./custom-skills", Scope: "project"}, }, { name: "too many args", @@ -76,30 +77,45 @@ func TestNewCmdInstall(t *testing.T) { { name: "alias add works", cli: "monalisa/skills-repo git-commit", - wantOpts: installOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Scope: "project"}, + wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Scope: "project"}, }, { - name: "dot-slash local path sets localPath", - cli: "./local-dir", - wantOpts: installOptions{SkillSource: "./local-dir", Scope: "project"}, + name: "from-local flag sets localPath", + cli: "--from-local ./local-dir", + wantOpts: InstallOptions{SkillSource: "./local-dir", Scope: "project", FromLocal: true}, wantLocalPath: true, }, { - name: "absolute path sets localPath", - cli: "/absolute/path", - wantOpts: installOptions{SkillSource: "/absolute/path", Scope: "project"}, + name: "from-local with absolute path", + cli: "--from-local /absolute/path", + wantOpts: InstallOptions{SkillSource: "/absolute/path", Scope: "project", FromLocal: true}, wantLocalPath: true, }, { - name: "tilde path sets localPath", - cli: "~/skills", - wantOpts: installOptions{SkillSource: "~/skills", Scope: "project"}, + name: "from-local with tilde path", + cli: "--from-local ~/skills", + wantOpts: InstallOptions{SkillSource: "~/skills", Scope: "project", FromLocal: true}, wantLocalPath: true, }, { name: "owner/repo does not set localPath", cli: "monalisa/skills-repo", - wantOpts: installOptions{SkillSource: "monalisa/skills-repo", Scope: "project"}, + wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", Scope: "project"}, + }, + { + name: "local-looking path without --from-local treated as repo", + cli: "./local-dir", + wantOpts: InstallOptions{SkillSource: "./local-dir", Scope: "project"}, + }, + { + name: "from-local without argument errors", + cli: "--from-local", + wantErr: true, + }, + { + name: "from-local with --pin is mutually exclusive", + cli: "--from-local ./local-dir --pin v1.0.0", + wantErr: true, }, } for _, tt := range tests { @@ -111,8 +127,8 @@ func TestNewCmdInstall(t *testing.T) { GitClient: &git.Client{}, } - var gotOpts *installOptions - cmd := NewCmdInstall(f, func(opts *installOptions) error { + var gotOpts *InstallOptions + cmd := NewCmdInstall(f, func(opts *InstallOptions) error { gotOpts = opts return nil }) @@ -138,6 +154,7 @@ func TestNewCmdInstall(t *testing.T) { assert.Equal(t, tt.wantOpts.Pin, gotOpts.Pin) assert.Equal(t, tt.wantOpts.Dir, gotOpts.Dir) assert.Equal(t, tt.wantOpts.Force, gotOpts.Force) + assert.Equal(t, tt.wantOpts.FromLocal, gotOpts.FromLocal) if tt.wantLocalPath { assert.NotEmpty(t, gotOpts.localPath, "expected localPath to be set") } else { @@ -152,7 +169,7 @@ func TestNewCmdInstall(t *testing.T) { f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} cmd := NewCmdInstall(f, nil) - assert.Equal(t, "install []", cmd.Use) + assert.Equal(t, "install [] [flags]", cmd.Use) assert.NotEmpty(t, cmd.Short) assert.NotEmpty(t, cmd.Long) assert.NotEmpty(t, cmd.Example) @@ -243,7 +260,7 @@ func TestInstallRun(t *testing.T) { isTTY bool setup func(t *testing.T) stubs func(*httpmock.Registry) - opts func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions + opts func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions verify func(t *testing.T) wantErr string wantStdout string @@ -252,9 +269,9 @@ func TestInstallRun(t *testing.T) { { name: "non-interactive without repo errors", isTTY: false, - opts: func(ios *iostreams.IOStreams, _ *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, _ *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, GitClient: &git.Client{RepoDir: t.TempDir()}, } @@ -269,9 +286,9 @@ func TestInstallRun(t *testing.T) { stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -292,9 +309,9 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -317,9 +334,9 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -342,9 +359,9 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -366,9 +383,9 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -390,9 +407,9 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -413,11 +430,11 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() targetDir := t.TempDir() require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "git-commit"), 0o755)) - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -440,11 +457,11 @@ func TestInstallRun(t *testing.T) { stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() targetDir := t.TempDir() require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "git-commit"), 0o755)) - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -466,9 +483,9 @@ func TestInstallRun(t *testing.T) { stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -496,9 +513,9 @@ func TestInstallRun(t *testing.T) { `{"path": "skills/bob/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobB"}` stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -527,9 +544,9 @@ func TestInstallRun(t *testing.T) { stubInstallFiles(reg, "monalisa", "skills-repo", "treeB", "blobB", "---\nname: xlsx-pro\ndescription: Bob version\n---\n# B\n") }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -546,9 +563,9 @@ func TestInstallRun(t *testing.T) { { name: "remote install with invalid repo argument errors", isTTY: false, - opts: func(ios *iostreams.IOStreams, _ *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, _ *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "invalid", @@ -572,9 +589,9 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -590,6 +607,32 @@ func TestInstallRun(t *testing.T) { wantStdout: "Installed git-commit", wantStderr: "v2.0.0", }, + { + name: "remote install shows pre-install disclaimer", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + wantStderr: "not verified by GitHub", + }, { name: "remote install outputs review hint", isTTY: true, @@ -599,9 +642,9 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -625,9 +668,9 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -639,7 +682,7 @@ func TestInstallRun(t *testing.T) { Dir: t.TempDir(), } }, - wantStdout: "SKILL.md", + wantStderr: "SKILL.md", }, { name: "remote install with inline version parses name and version", @@ -656,9 +699,9 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -684,9 +727,9 @@ func TestInstallRun(t *testing.T) { // installer.Install: tree + blob (again, for writing files) stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -709,9 +752,9 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -737,14 +780,14 @@ func TestInstallRun(t *testing.T) { `{"path": "xlsx-pro/SKILL.md", "type": "blob", "sha": "blob1"}` stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() pm := &prompter.PrompterMock{ MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { return []string{allSkillsKey}, nil }, } - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, @@ -784,14 +827,14 @@ func TestInstallRun(t *testing.T) { stubInstallFiles(reg, "monalisa", "skills-repo", "treeB", "blobB", "---\nname: xlsx-pro\ndescription: Bob\n---\n# B\n") }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() pm := &prompter.PrompterMock{ MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { return []string{allSkillsKey}, nil }, } - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, @@ -814,9 +857,9 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -864,7 +907,7 @@ func TestInstallRun(t *testing.T) { stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-skill-01", "blob-skill-01", "---\nname: skill-01\ndescription: Does skill-01 things\n---\n# Skill\n") }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() pm := &prompter.PrompterMock{ MultiSelectWithSearchFunc: func(prompt, searchPrompt string, defaults, persistentOptions []string, searchFunc func(string) prompter.MultiSelectSearchResult) ([]string, error) { @@ -884,7 +927,7 @@ func TestInstallRun(t *testing.T) { return 0, nil }, } - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, @@ -905,14 +948,14 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() pm := &prompter.PrompterMock{ SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { return 0, nil }, } - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, @@ -933,7 +976,7 @@ func TestInstallRun(t *testing.T) { stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() destDir := t.TempDir() writeLocalTestSkill(t, destDir, "git-commit", gitCommitContent) @@ -942,7 +985,7 @@ func TestInstallRun(t *testing.T) { return false, nil }, } - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, @@ -966,9 +1009,9 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -996,14 +1039,14 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() pm := &prompter.PrompterMock{ SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { return 0, nil // project scope }, } - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, @@ -1030,7 +1073,7 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() destDir := t.TempDir() existingContent := heredoc.Doc(` @@ -1050,7 +1093,7 @@ func TestInstallRun(t *testing.T) { return true, nil }, } - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, @@ -1068,9 +1111,9 @@ func TestInstallRun(t *testing.T) { { name: "unsupported host returns error", stubs: func(reg *httpmock.Registry) {}, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: &prompter.PrompterMock{}, @@ -1095,7 +1138,7 @@ func TestInstallRun(t *testing.T) { httpmock.StringResponse(fmt.Sprintf(`{"sha": "blob-gc", "content": %q, "encoding": "base64"}`, encoded))) stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() pm := &prompter.PrompterMock{ MultiSelectWithSearchFunc: func(prompt, searchPrompt string, defaults, persistentOptions []string, searchFunc func(string) prompter.MultiSelectSearchResult) ([]string, error) { @@ -1105,7 +1148,7 @@ func TestInstallRun(t *testing.T) { return 0, nil }, } - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, @@ -1126,7 +1169,7 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() pm := &prompter.PrompterMock{ InputFunc: func(prompt, defaultValue string) (string, error) { @@ -1136,7 +1179,7 @@ func TestInstallRun(t *testing.T) { return 0, nil }, } - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, @@ -1157,14 +1200,14 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() pm := &prompter.PrompterMock{ SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { return 1, nil // user scope }, } - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, @@ -1186,7 +1229,7 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() destDir := t.TempDir() // Existing skill without github metadata in frontmatter @@ -1204,7 +1247,7 @@ func TestInstallRun(t *testing.T) { return true, nil }, } - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, @@ -1232,9 +1275,9 @@ func TestInstallRun(t *testing.T) { stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) stubInstallFiles(reg, "monalisa", "skills-repo", "treeGC", "blobGC", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -1259,7 +1302,7 @@ func TestInstallRun(t *testing.T) { stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() pm := &prompter.PrompterMock{ MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) { @@ -1269,7 +1312,7 @@ func TestInstallRun(t *testing.T) { return 0, nil // project scope }, } - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, @@ -1363,7 +1406,7 @@ func TestInstallRun_DeduplicatesSharedProjectDirAcrossHosts(t *testing.T) { }, } - err := installRun(&installOptions{ + err := installRun(&InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, @@ -1382,7 +1425,7 @@ func TestRunLocalInstall(t *testing.T) { name string isTTY bool setup func(t *testing.T, sourceDir, targetDir string) - opts func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions + opts func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions verify func(t *testing.T, targetDir string) wantErr string wantStdout string @@ -1401,9 +1444,9 @@ func TestRunLocalInstall(t *testing.T) { # Git Commit `)) }, - opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, SkillSource: sourceDir, localPath: sourceDir, @@ -1438,9 +1481,9 @@ func TestRunLocalInstall(t *testing.T) { `) require.NoError(t, os.WriteFile(filepath.Join(sourceDir, "SKILL.md"), []byte(content), 0o644)) }, - opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, SkillSource: sourceDir, localPath: sourceDir, @@ -1465,14 +1508,14 @@ func TestRunLocalInstall(t *testing.T) { fmt.Sprintf("---\nname: xlsx-pro\ndescription: %s xlsx-pro\n---\n# Test\n", ns)) } }, - opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { t.Helper() pm := &prompter.PrompterMock{ MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { return []string{allSkillsKey}, nil }, } - return &installOptions{ + return &InstallOptions{ IO: ios, SkillSource: sourceDir, localPath: sourceDir, @@ -1505,14 +1548,14 @@ func TestRunLocalInstall(t *testing.T) { } require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "alice", "xlsx-pro"), 0o755)) }, - opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { t.Helper() pm := &prompter.PrompterMock{ MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { return []string{allSkillsKey}, nil }, } - return &installOptions{ + return &InstallOptions{ IO: ios, SkillSource: sourceDir, localPath: sourceDir, @@ -1541,9 +1584,9 @@ func TestRunLocalInstall(t *testing.T) { `)) require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "git-commit"), 0o755)) }, - opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, SkillSource: sourceDir, localPath: sourceDir, @@ -1561,9 +1604,9 @@ func TestRunLocalInstall(t *testing.T) { name: "local install with no skills found errors", isTTY: false, setup: func(_ *testing.T, _, _ string) {}, - opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, SkillSource: sourceDir, localPath: sourceDir, @@ -1590,9 +1633,9 @@ func TestRunLocalInstall(t *testing.T) { # Git Commit `)) }, - opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, SkillSource: sourceDir, localPath: sourceDir, @@ -1620,9 +1663,9 @@ func TestRunLocalInstall(t *testing.T) { # Git Commit `)) }, - opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, SkillSource: sourceDir, localPath: sourceDir, @@ -1657,9 +1700,9 @@ func TestRunLocalInstall(t *testing.T) { # Code Review `)) }, - opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, SkillSource: sourceDir, localPath: sourceDir, @@ -1686,9 +1729,9 @@ func TestRunLocalInstall(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(skillDir, "scripts", "run.sh"), []byte("#!/bin/bash"), 0o644)) }, - opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, SkillSource: sourceDir, localPath: sourceDir, @@ -1701,7 +1744,7 @@ func TestRunLocalInstall(t *testing.T) { GitClient: &git.Client{RepoDir: t.TempDir()}, } }, - wantStdout: "SKILL.md", + wantStderr: "SKILL.md", }, { name: "local path with tilde expansion", @@ -1716,11 +1759,11 @@ func TestRunLocalInstall(t *testing.T) { # Git Commit `)) }, - opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { t.Helper() t.Setenv("HOME", sourceDir) t.Setenv("USERPROFILE", sourceDir) - return &installOptions{ + return &InstallOptions{ IO: ios, SkillSource: "~/", localPath: "~/", @@ -1748,11 +1791,11 @@ func TestRunLocalInstall(t *testing.T) { # Git Commit `)) }, - opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { t.Helper() t.Setenv("HOME", sourceDir) t.Setenv("USERPROFILE", sourceDir) - return &installOptions{ + return &InstallOptions{ IO: ios, SkillSource: "~", localPath: "~", @@ -1780,9 +1823,9 @@ func TestRunLocalInstall(t *testing.T) { # Git Commit `)) }, - opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, SkillSource: sourceDir, localPath: sourceDir, @@ -1898,3 +1941,42 @@ func Test_printReviewHint(t *testing.T) { }) } } + +func Test_printPreInstallDisclaimer(t *testing.T) { + ios, _, _, _ := iostreams.Test() + cs := ios.ColorScheme() + var buf strings.Builder + printPreInstallDisclaimer(&buf, cs) + output := buf.String() + assert.Contains(t, output, "not verified by GitHub") + assert.Contains(t, output, "prompt") + assert.Contains(t, output, "malicious") +} + +func Test_selectSkillsWithSelector_noDisclaimer(t *testing.T) { + ios, _, _, stderr := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + skills := []discovery.Skill{ + {Name: "git-commit", Convention: "skills", Path: "skills/git-commit/SKILL.md"}, + } + + pm := &prompter.PrompterMock{ + MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { + return []string{"git-commit"}, nil + }, + } + + opts := &InstallOptions{ + IO: ios, + Prompter: pm, + } + + _, err := selectSkillsWithSelector(opts, skills, true, skillSelector{ + matchByName: matchSkillByName, + sourceHint: "owner/repo", + }) + require.NoError(t, err) + assert.NotContains(t, stderr.String(), "not verified by GitHub") +} diff --git a/pkg/cmd/skills/install/install_windows_test.go b/pkg/cmd/skills/install/install_windows_test.go deleted file mode 100644 index 8a184fac4..000000000 --- a/pkg/cmd/skills/install/install_windows_test.go +++ /dev/null @@ -1,63 +0,0 @@ -//go:build windows - -package install - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestIsLocalPath_Windows(t *testing.T) { - tests := []struct { - name string - arg string - want bool - }{ - // Backslash-relative paths that only exist on Windows. - {`dot-backslash prefix`, `.\skills`, true}, - {`dotdot-backslash prefix`, `..\other`, true}, - {`drive-absolute path`, `C:\Users\me\skills`, true}, - {`drive-relative path`, `D:\projects`, true}, - {`UNC path`, `\\server\share\skills`, true}, - - // Forward-slash forms should still work on Windows. - {`dot-slash prefix`, `./skills`, true}, - {`dotdot-slash prefix`, `../other`, true}, - {`current dir`, `.`, true}, - {`absolute unix-style`, `/tmp/skills`, true}, - {`tilde prefix`, `~/skills`, true}, - - // owner/repo should never be treated as local. - {`owner-repo`, `github/awesome-copilot`, false}, - {`simple name`, `awesome-copilot`, false}, - {`empty string`, ``, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := isLocalPath(tt.arg) - assert.Equal(t, tt.want, got, "isLocalPath(%q)", tt.arg) - }) - } -} - -func TestIsLocalPath_WindowsExistingDir(t *testing.T) { - // A directory that exists on disk should be detected as local even when - // its name looks like owner/repo (the os.Stat safety-net). - dir := t.TempDir() - nested := filepath.Join(dir, "owner", "repo") - if err := os.MkdirAll(nested, 0o755); err != nil { - t.Fatal(err) - } - - // Use a relative path that happens to contain a backslash separator. - rel, err := filepath.Rel(".", nested) - if err != nil { - // If we can't compute a relative path, just use the absolute one. - rel = nested - } - assert.True(t, isLocalPath(rel), "existing dir should be detected as local: %s", rel) -} diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index ee33b04c1..270912478 100644 --- a/pkg/cmd/skills/preview/preview.go +++ b/pkg/cmd/skills/preview/preview.go @@ -21,7 +21,7 @@ import ( "github.com/spf13/cobra" ) -type previewOptions struct { +type PreviewOptions struct { IO *iostreams.IOStreams HttpClient func() (*http.Client, error) Prompter prompter.Prompter @@ -36,8 +36,8 @@ type previewOptions struct { } // NewCmdPreview creates the "skills preview" command. -func NewCmdPreview(f *cmdutil.Factory, runF func(*previewOptions) error) *cobra.Command { - opts := &previewOptions{ +func NewCmdPreview(f *cmdutil.Factory, runF func(*PreviewOptions) error) *cobra.Command { + opts := &PreviewOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, Prompter: f.Prompter, @@ -49,7 +49,7 @@ func NewCmdPreview(f *cmdutil.Factory, runF func(*previewOptions) error) *cobra. cmd := &cobra.Command{ Use: "preview []", - Short: "Preview a skill from a GitHub repository", + Short: "Preview a skill from a GitHub repository (preview)", Long: heredoc.Doc(` Render a skill's SKILL.md content in the terminal. This fetches the skill file from the repository and displays it using the configured @@ -109,7 +109,7 @@ func NewCmdPreview(f *cmdutil.Factory, runF func(*previewOptions) error) *cobra. return cmd } -func previewRun(opts *previewOptions) error { +func previewRun(opts *PreviewOptions) error { cs := opts.IO.ColorScheme() repo := opts.repo @@ -186,7 +186,7 @@ func previewRun(opts *previewOptions) error { } // renderAllFiles dumps the tree, SKILL.md, and all extra files through the pager. -func renderAllFiles(opts *previewOptions, cs *iostreams.ColorScheme, skill discovery.Skill, +func renderAllFiles(opts *PreviewOptions, cs *iostreams.ColorScheme, skill discovery.Skill, files []discovery.SkillFile, rendered string, extraFiles []discovery.SkillFile, apiClient *api.Client, hostname, owner, repo string) error { @@ -213,11 +213,11 @@ func renderAllFiles(opts *previewOptions, cs *iostreams.ColorScheme, skill disco totalBytes := 0 for _, f := range extraFiles { if fetched >= maxFiles { - fmt.Fprintf(out, "\n%s\n", cs.Muted(fmt.Sprintf("(skipped remaining files — showing first %d)", maxFiles))) + fmt.Fprintf(out, "\n%s\n", cs.Muted(fmt.Sprintf("(skipped remaining files, showing first %d)", maxFiles))) break } if totalBytes+f.Size > maxTotalBytes && fetched > 0 { - fmt.Fprintf(out, "\n%s\n", cs.Muted("(skipped remaining files — size limit reached)")) + fmt.Fprintf(out, "\n%s\n", cs.Muted("(skipped remaining files, size limit reached)")) break } fileContent, fetchErr := discovery.FetchBlob(apiClient, hostname, owner, repo, f.SHA) @@ -238,7 +238,7 @@ func renderAllFiles(opts *previewOptions, cs *iostreams.ColorScheme, skill disco } // renderInteractive shows the file tree, then a picker to browse individual files. -func renderInteractive(opts *previewOptions, cs *iostreams.ColorScheme, skill discovery.Skill, +func renderInteractive(opts *PreviewOptions, cs *iostreams.ColorScheme, skill discovery.Skill, files []discovery.SkillFile, renderedSkillMD string, extraFiles []discovery.SkillFile, apiClient *api.Client, hostname, owner, repo string) error { @@ -254,7 +254,7 @@ func renderInteractive(opts *previewOptions, cs *iostreams.ColorScheme, skill di choices = append(choices, f.Path) } - // Save original stdout — StopPager closes IO.Out, so we need to + // Save original stdout. StopPager closes IO.Out, so we need to // restore a working writer before each StartPager call. originalOut := opts.IO.Out @@ -276,7 +276,7 @@ func renderInteractive(opts *previewOptions, cs *iostreams.ColorScheme, skill di } else { selectedFile := extraFiles[idx-1] - // Fetch on demand — don't hold blob data in memory + // Fetch on demand; don't hold blob data in memory fileContent, fetchErr := discovery.FetchBlob(apiClient, hostname, owner, repo, selectedFile.SHA) if fetchErr != nil { fmt.Fprintf(opts.IO.ErrOut, "%s could not fetch %s: %v\n", cs.Red("!"), selectedFile.Path, fetchErr) @@ -296,7 +296,7 @@ func renderInteractive(opts *previewOptions, cs *iostreams.ColorScheme, skill di } } -func (opts *previewOptions) renderFile(filePath, content string) string { +func (opts *PreviewOptions) renderFile(filePath, content string) string { if opts.RenderFile != nil { return opts.RenderFile(filePath, content) } @@ -304,7 +304,7 @@ func (opts *previewOptions) renderFile(filePath, content string) string { return renderMarkdownPreview(opts.IO, filePath, content) } -func renderSelectedFilePreview(opts *previewOptions, filePath, content string) string { +func renderSelectedFilePreview(opts *PreviewOptions, filePath, content string) string { if !isMarkdownFile(filePath) { return content } @@ -340,7 +340,7 @@ func isMarkdownFile(filePath string) bool { } } -func selectSkill(opts *previewOptions, skills []discovery.Skill) (discovery.Skill, error) { +func selectSkill(opts *PreviewOptions, skills []discovery.Skill) (discovery.Skill, error) { if opts.SkillName != "" { for _, s := range skills { if s.DisplayName() == opts.SkillName || s.Name == opts.SkillName { diff --git a/pkg/cmd/skills/preview/preview_test.go b/pkg/cmd/skills/preview/preview_test.go index debdfbff2..474ce88b5 100644 --- a/pkg/cmd/skills/preview/preview_test.go +++ b/pkg/cmd/skills/preview/preview_test.go @@ -73,8 +73,8 @@ func TestNewCmdPreview(t *testing.T) { Prompter: &prompter.PrompterMock{}, } - var gotOpts *previewOptions - cmd := NewCmdPreview(f, func(opts *previewOptions) error { + var gotOpts *PreviewOptions + cmd := NewCmdPreview(f, func(opts *PreviewOptions) error { gotOpts = opts return nil }) @@ -112,7 +112,7 @@ func TestPreviewRun(t *testing.T) { tests := []struct { name string - opts *previewOptions + opts *PreviewOptions tty bool httpStubs func(*httpmock.Registry) wantStdout string @@ -121,7 +121,7 @@ func TestPreviewRun(t *testing.T) { { name: "preview specific skill", tty: true, - opts: &previewOptions{ + opts: &PreviewOptions{ repo: ghrepo.New("github", "awesome-copilot"), SkillName: "my-skill", }, @@ -164,7 +164,7 @@ func TestPreviewRun(t *testing.T) { { name: "preview with display name match", tty: true, - opts: &previewOptions{ + opts: &PreviewOptions{ repo: ghrepo.New("owner", "repo"), SkillName: "ns/my-skill", }, @@ -208,7 +208,7 @@ func TestPreviewRun(t *testing.T) { { name: "skill not found", tty: true, - opts: &previewOptions{ + opts: &PreviewOptions{ repo: ghrepo.New("owner", "repo"), SkillName: "nonexistent", }, @@ -238,7 +238,7 @@ func TestPreviewRun(t *testing.T) { { name: "no skill name non-interactive errors", tty: false, - opts: &previewOptions{ + opts: &PreviewOptions{ repo: ghrepo.New("owner", "repo"), }, httpStubs: func(reg *httpmock.Registry) { @@ -267,7 +267,7 @@ func TestPreviewRun(t *testing.T) { { name: "preview with explicit version", tty: true, - opts: &previewOptions{ + opts: &PreviewOptions{ repo: ghrepo.New("github", "awesome-copilot"), SkillName: "my-skill", Version: "abc123def456", @@ -350,7 +350,7 @@ func TestPreviewRun(t *testing.T) { func TestPreviewRun_UnsupportedHost(t *testing.T) { ios, _, _, _ := iostreams.Test() - err := previewRun(&previewOptions{ + err := previewRun(&PreviewOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{}, nil }, repo: ghrepo.NewWithHost("github", "awesome-copilot", "acme.ghes.com"), @@ -410,7 +410,7 @@ func TestPreviewRun_Interactive(t *testing.T) { }, } - opts := &previewOptions{ + opts := &PreviewOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, @@ -504,7 +504,7 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { }, } - opts := &previewOptions{ + opts := &PreviewOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, @@ -583,7 +583,7 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { }, } - opts := &previewOptions{ + opts := &PreviewOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, @@ -612,7 +612,7 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { ios.SetStdinTTY(false) ios.SetColorEnabled(false) - opts := &previewOptions{ + opts := &PreviewOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: &prompter.PrompterMock{}, @@ -718,7 +718,7 @@ func TestPreviewRun_RenderLimits(t *testing.T) { ios.SetStdoutTTY(false) ios.SetStdinTTY(false) - opts := &previewOptions{ + opts := &PreviewOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: &prompter.PrompterMock{}, @@ -749,13 +749,13 @@ func TestPreviewRun_RenderLimits(t *testing.T) { httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blob000"), httpmock.StringResponse(fmt.Sprintf(`{"sha": "blob000", "content": "%s", "encoding": "base64"}`, bigContent)), ) - // blob001 should NOT be fetched — size limit reached + // blob001 should NOT be fetched (size limit reached) ios, _, stdout, _ := iostreams.Test() ios.SetStdoutTTY(false) ios.SetStdinTTY(false) - opts := &previewOptions{ + opts := &PreviewOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: &prompter.PrompterMock{}, @@ -787,7 +787,7 @@ func TestPreviewRun_RenderLimits(t *testing.T) { ios.SetStdoutTTY(false) ios.SetStdinTTY(false) - opts := &previewOptions{ + opts := &PreviewOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: &prompter.PrompterMock{}, diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index 22d87bb73..82202514b 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -27,25 +27,21 @@ import ( "github.com/spf13/cobra" ) -// publishOptions holds all dependencies and user-provided flags for the publish command. -type publishOptions struct { +// PublishOptions holds all dependencies and user-provided flags for the publish command. +type PublishOptions struct { IO *iostreams.IOStreams HttpClient func() (*http.Client, error) Config func() (gh.Config, error) Prompter prompter.Prompter GitClient *git.Client - // Arguments - Dir string // directory to validate (default: cwd) + Dir string + Fix bool + DryRun bool + Tag string - // Flags - Fix bool // --fix flag: auto-fix issues where possible - DryRun bool // --dry-run flag: validate only, don't publish - Tag string // --tag flag: release tag to create - - // Testing overrides - client *api.Client // injectable for tests; nil means use factory HttpClient - host string // API host (e.g. "github.com"); resolved from config in production + client *api.Client // injectable for tests; nil means use factory + host string // resolved from config in production } // publishDiagnostic is a single validation finding. @@ -90,8 +86,8 @@ type repoSecurityResponse struct { } // NewCmdPublish creates the "skills publish" command. -func NewCmdPublish(f *cmdutil.Factory, runF func(*publishOptions) error) *cobra.Command { - opts := &publishOptions{ +func NewCmdPublish(f *cmdutil.Factory, runF func(*PublishOptions) error) *cobra.Command { + opts := &PublishOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, Config: f.Config, @@ -100,8 +96,8 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*publishOptions) error) *cobra. } cmd := &cobra.Command{ - Use: "publish []", - Short: "Validate and publish skills to a GitHub repository", + Use: "publish [] [flags]", + Short: "Validate and publish skills to a GitHub repository (preview)", Long: heredoc.Doc(` Validate a local repository's skills against the Agent Skills specification and publish them by creating a GitHub release. @@ -158,7 +154,7 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*publishOptions) error) *cobra. return cmd } -func publishRun(opts *publishOptions) error { +func publishRun(opts *PublishOptions) error { dir := opts.Dir if dir == "" { var err error @@ -478,7 +474,7 @@ func fetchTags(client *api.Client, host, owner, repo string) []tagEntry { } // runPublishRelease handles the interactive publish flow: topic, tag, release, immutability. -func runPublishRelease(opts *publishOptions, client *api.Client, host, owner, repo, dir string, hasTopic bool, existingTags []tagEntry) error { +func runPublishRelease(opts *PublishOptions, client *api.Client, host, owner, repo, dir string, hasTopic bool, existingTags []tagEntry) error { cs := opts.IO.ColorScheme() canPrompt := opts.IO.CanPrompt() @@ -515,7 +511,7 @@ func runPublishRelease(opts *publishOptions, client *api.Client, host, owner, re if canPrompt { strategies := []string{ - fmt.Sprintf("Semver (recommended) — %s", suggested), + fmt.Sprintf("Semver (recommended): %s", suggested), "Custom tag", } idx, err := opts.Prompter.Select("Tagging strategy:", "", strategies) @@ -550,7 +546,7 @@ func runPublishRelease(opts *publishOptions, client *api.Client, host, owner, re // Validate tag doesn't already exist for _, t := range existingTags { if t.Name == tag { - return fmt.Errorf("tag %s already exists — choose a different version", tag) + return fmt.Errorf("tag %s already exists; choose a different version", tag) } } @@ -565,7 +561,7 @@ func runPublishRelease(opts *publishOptions, client *api.Client, host, owner, re if enableImmutable { if err := enableImmutableReleases(client, host, owner, repo); err != nil { fmt.Fprintf(opts.IO.ErrOut, "%s Could not enable immutable releases: %v\n", cs.WarningIcon(), err) - fmt.Fprintf(opts.IO.ErrOut, " Enable manually in Settings → General → Releases\n") + fmt.Fprintf(opts.IO.ErrOut, " Enable manually in Settings > General > Releases\n") } else { fmt.Fprintf(opts.IO.Out, "%s Enabled immutable releases\n", cs.SuccessIcon()) } @@ -707,7 +703,7 @@ func checkTagProtection(client *api.Client, host, owner, repo string) []publishD return []publishDiagnostic{{ severity: "warning", - message: "no active tag protection rulesets found — consider protecting tags to ensure immutable releases (Settings → Rules → Rulesets)", + message: "no active tag protection rulesets found. Consider protecting tags to ensure immutable releases (Settings > Rules > Rulesets)", }} } @@ -732,14 +728,14 @@ func checkSecuritySettings(client *api.Client, host, owner, repo, skillsDir stri if sa.SecretScanning == nil || sa.SecretScanning.Status != "enabled" { diagnostics = append(diagnostics, publishDiagnostic{ severity: "warning", - message: "secret scanning is not enabled — recommended to prevent accidental credential exposure (gh repo edit --enable-secret-scanning)", + message: "secret scanning is not enabled. Recommended to prevent accidental credential exposure (gh repo edit --enable-secret-scanning)", }) } if sa.SecretScanningPushProtection == nil || sa.SecretScanningPushProtection.Status != "enabled" { diagnostics = append(diagnostics, publishDiagnostic{ severity: "warning", - message: "secret scanning push protection is not enabled — blocks pushes containing secrets (gh repo edit --enable-secret-scanning-push-protection)", + message: "secret scanning push protection is not enabled. Blocks pushes containing secrets (gh repo edit --enable-secret-scanning-push-protection)", }) } @@ -750,7 +746,7 @@ func checkSecuritySettings(client *api.Client, host, owner, repo, skillsDir stri if err := client.REST(host, "GET", alertsPath, nil, new([]interface{})); err != nil { diagnostics = append(diagnostics, publishDiagnostic{ severity: "info", - message: "skills include code files but code scanning does not appear to be configured (Settings → Code security → Code scanning)", + message: "skills include code files but code scanning does not appear to be configured (Settings > Code security > Code scanning)", }) } } @@ -760,7 +756,7 @@ func checkSecuritySettings(client *api.Client, host, owner, repo, skillsDir stri if err := client.REST(host, "GET", dependabotPath, nil, nil); err != nil { diagnostics = append(diagnostics, publishDiagnostic{ severity: "info", - message: "skills include dependency manifests but Dependabot alerts do not appear to be enabled (Settings → Code security → Dependabot)", + message: "skills include dependency manifests but Dependabot alerts do not appear to be enabled (Settings > Code security > Dependabot)", }) } } @@ -825,7 +821,15 @@ func checkInstalledSkillDirs(gitClient *git.Client, repoDir string) []publishDia if gitClient != nil { ignoreGitClient := gitClient.Copy() ignoreGitClient.RepoDir = repoDir - if ignoreGitClient.IsIgnored(context.Background(), relPath) { + ignored, err := ignoreGitClient.IsIgnored(context.Background(), relPath) + if ignored { + continue + } + if err != nil { + diagnostics = append(diagnostics, publishDiagnostic{ + severity: "warning", + message: fmt.Sprintf("%s/ may contain installed skills that are not gitignored (could not verify: %v)", relPath, err), + }) continue } } @@ -883,7 +887,7 @@ func detectGitHubRemote(gitClient *git.Client) (ghrepo.Interface, error) { // Fall back to any remote that points to GitHub remotes, err := gitClient.Remotes(context.Background()) if err != nil { - return nil, nil + return nil, nil //nolint:nilerr // failing to list remotes is not an error; it just means no repo detected } for _, r := range remotes { if r.Name == "origin" { @@ -907,14 +911,14 @@ func detectGitHubRemote(gitClient *git.Client) (ghrepo.Interface, error) { func parseGitHubURL(rawURL string) (ghrepo.Interface, error) { u, err := git.ParseURL(rawURL) if err != nil { - return nil, nil + return nil, nil //nolint:nilerr // unparseable URL means it's not a GitHub remote } r, err := ghrepo.FromURL(u) if err != nil { - return nil, nil + return nil, nil //nolint:nilerr // URL didn't match GitHub repo format } if err := source.ValidateSupportedHost(r.RepoHost()); err != nil { - return nil, nil + return nil, nil //nolint:nilerr // non-GitHub host is silently ignored } return r, nil } @@ -930,7 +934,7 @@ func detectMissingRepoDiagnostic(gitClient *git.Client, dir string) []publishDia if _, err := dirGitClient.GitDir(context.Background()); err != nil { return []publishDiagnostic{{ severity: "warning", - message: "not a git repository — initialize with: git init && gh repo create", + message: "not a git repository. Initialize with: git init && gh repo create", }} } @@ -938,7 +942,7 @@ func detectMissingRepoDiagnostic(gitClient *git.Client, dir string) []publishDia if err != nil || len(remotes) == 0 { return []publishDiagnostic{{ severity: "warning", - message: "no git remote found — create a GitHub repository with: gh repo create", + message: "no git remote found. Create a GitHub repository with: gh repo create", }} } @@ -950,11 +954,11 @@ func detectMissingRepoDiagnostic(gitClient *git.Client, dir string) []publishDia } return []publishDiagnostic{{ severity: "warning", - message: fmt.Sprintf("remote %q is not a GitHub repository — skills must be hosted on GitHub for discovery", strings.Join(urls, ", ")), + message: fmt.Sprintf("remote %q is not a GitHub repository. Skills must be hosted on GitHub for discovery", strings.Join(urls, ", ")), }} } -func renderDiagnosticsTTY(opts *publishOptions, skillDirs []string, diagnostics []publishDiagnostic, errors, warnings, fixes int, owner, repo string) { +func renderDiagnosticsTTY(opts *PublishOptions, skillDirs []string, diagnostics []publishDiagnostic, errors, warnings, fixes int, owner, repo string) { cs := opts.IO.ColorScheme() // Separate info messages from errors/warnings for cleaner output @@ -1016,7 +1020,7 @@ func renderDiagnosticsTTY(opts *publishOptions, skillDirs []string, diagnostics } } -func renderDiagnosticsPlain(opts *publishOptions, diagnostics []publishDiagnostic, errors, warnings int) { +func renderDiagnosticsPlain(opts *PublishOptions, diagnostics []publishDiagnostic, errors, warnings int) { for _, d := range diagnostics { if d.severity == "info" { continue diff --git a/pkg/cmd/skills/publish/publish_test.go b/pkg/cmd/skills/publish/publish_test.go index d54d04ad3..fdcaa6631 100644 --- a/pkg/cmd/skills/publish/publish_test.go +++ b/pkg/cmd/skills/publish/publish_test.go @@ -76,12 +76,12 @@ func TestNewCmdPublish(t *testing.T) { name string cli string wantsErr bool - wantsOpts publishOptions + wantsOpts PublishOptions }{ { name: "all flags", cli: "./monalisa-skills --dry-run --fix --tag v1.0.0", - wantsOpts: publishOptions{ + wantsOpts: PublishOptions{ Dir: "./monalisa-skills", DryRun: true, Fix: true, @@ -91,19 +91,19 @@ func TestNewCmdPublish(t *testing.T) { { name: "directory only", cli: "./octocat-repo", - wantsOpts: publishOptions{ + wantsOpts: PublishOptions{ Dir: "./octocat-repo", }, }, { name: "no args leaves dir empty", cli: "", - wantsOpts: publishOptions{}, + wantsOpts: PublishOptions{}, }, { name: "dry-run flag only", cli: "--dry-run", - wantsOpts: publishOptions{ + wantsOpts: PublishOptions{ DryRun: true, }, }, @@ -113,8 +113,8 @@ func TestNewCmdPublish(t *testing.T) { ios, _, _, _ := iostreams.Test() f := cmdutil.Factory{IOStreams: ios} - var gotOpts *publishOptions - cmd := NewCmdPublish(&f, func(opts *publishOptions) error { + var gotOpts *PublishOptions + cmd := NewCmdPublish(&f, func(opts *PublishOptions) error { gotOpts = opts return nil }) @@ -151,7 +151,7 @@ func TestPublishRun_UnsupportedHost(t *testing.T) { `)) ios, _, _, _ := iostreams.Test() - err := publishRun(&publishOptions{ + err := publishRun(&PublishOptions{ IO: ios, Dir: dir, GitClient: newTestGitClient(t, map[string]string{"origin": "https://github.com/monalisa/skills-repo.git"}), @@ -167,7 +167,7 @@ func TestPublishRun(t *testing.T) { isTTY bool setup func(t *testing.T, dir string) stubs func(*httpmock.Registry) - opts func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions + opts func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions verify func(t *testing.T, dir string) wantErr string wantStdout string @@ -176,9 +176,9 @@ func TestPublishRun(t *testing.T) { { name: "no skills directory", setup: func(_ *testing.T, _ string) {}, - opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{IO: ios, Dir: dir} + return &PublishOptions{IO: ios, Dir: dir} }, wantErr: "no skills/ directory", }, @@ -188,9 +188,9 @@ func TestPublishRun(t *testing.T) { t.Helper() require.NoError(t, os.MkdirAll(filepath.Join(dir, "skills", "empty-skill"), 0o755)) }, - opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{IO: ios, Dir: dir} + return &PublishOptions{IO: ios, Dir: dir} }, wantErr: "validation failed", wantStdout: "missing SKILL.md", @@ -206,9 +206,9 @@ func TestPublishRun(t *testing.T) { Body text. `)) }, - opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{IO: ios, Dir: dir} + return &PublishOptions{IO: ios, Dir: dir} }, wantErr: "validation failed", wantStdout: "missing required field: name", @@ -225,9 +225,9 @@ func TestPublishRun(t *testing.T) { Body. `)) }, - opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{IO: ios, Dir: dir} + return &PublishOptions{IO: ios, Dir: dir} }, wantErr: "validation failed", wantStdout: "does not match directory name", @@ -244,9 +244,9 @@ func TestPublishRun(t *testing.T) { Body. `)) }, - opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{IO: ios, Dir: dir} + return &PublishOptions{IO: ios, Dir: dir} }, wantErr: "validation failed", wantStdout: "naming convention", @@ -268,9 +268,9 @@ func TestPublishRun(t *testing.T) { stubs: func(reg *httpmock.Registry) { stubAllSecureRemote(reg, "monalisa", "skills-repo") }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, DryRun: true, @@ -320,9 +320,9 @@ func TestPublishRun(t *testing.T) { }), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, Tag: "v1.0.1", @@ -356,9 +356,9 @@ func TestPublishRun(t *testing.T) { Body. `)) }, - opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{IO: ios, Dir: dir, Fix: true} + return &PublishOptions{IO: ios, Dir: dir, Fix: true} }, wantStdout: "stripped install metadata", verify: func(t *testing.T, dir string) { @@ -386,9 +386,9 @@ func TestPublishRun(t *testing.T) { Body. `)) }, - opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{IO: ios, Dir: dir, Fix: false} + return &PublishOptions{IO: ios, Dir: dir, Fix: false} }, wantErr: "validation failed", wantStdout: "--fix", @@ -405,9 +405,9 @@ func TestPublishRun(t *testing.T) { Body. `)) }, - opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{IO: ios, Dir: dir} + return &PublishOptions{IO: ios, Dir: dir} }, wantStdout: "license", }, @@ -426,9 +426,9 @@ func TestPublishRun(t *testing.T) { Body. `)) }, - opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{IO: ios, Dir: dir} + return &PublishOptions{IO: ios, Dir: dir} }, wantErr: "validation failed", wantStdout: "allowed-tools must be a string", @@ -473,9 +473,9 @@ func TestPublishRun(t *testing.T) { }), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, GitClient: newTestGitClient(t, map[string]string{ @@ -525,9 +525,9 @@ func TestPublishRun(t *testing.T) { }), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, GitClient: newTestGitClient(t, map[string]string{ @@ -587,9 +587,9 @@ func TestPublishRun(t *testing.T) { httpmock.StatusStringResponse(404, "not found"), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, DryRun: true, @@ -651,9 +651,9 @@ func TestPublishRun(t *testing.T) { httpmock.StatusStringResponse(404, "not found"), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, DryRun: true, @@ -680,14 +680,14 @@ func TestPublishRun(t *testing.T) { Body. `)) }, - opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { t.Helper() require.NoError(t, os.MkdirAll(filepath.Join(dir, ".agents", "skills", "installed"), 0o755)) runGitInDir(t, dir, "init", "--initial-branch=main") runGitInDir(t, dir, "config", "user.email", "monalisa@github.com") runGitInDir(t, dir, "config", "user.name", "Monalisa Octocat") - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, GitClient: &git.Client{RepoDir: dir}, @@ -716,9 +716,9 @@ func TestPublishRun(t *testing.T) { runGitInDir(t, dir, "add", ".gitignore") runGitInDir(t, dir, "commit", "-m", "init") }, - opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, GitClient: &git.Client{RepoDir: dir}, @@ -730,6 +730,31 @@ func TestPublishRun(t *testing.T) { // The key assertion: .gitignored dirs should NOT produce a warning }, }, + { + name: "installed skill dirs git error warns about unverified status", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + // Create install dir but do NOT init git so check-ignore will fail + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".agents", "skills", "installed"), 0o755)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{ + IO: ios, + Dir: dir, + GitClient: &git.Client{RepoDir: dir}, + } + }, + wantStdout: "may contain installed skills that are not gitignored", + }, { name: "no GitHub remote warns", setup: func(t *testing.T, dir string) { @@ -747,9 +772,9 @@ func TestPublishRun(t *testing.T) { runGitInDir(t, dir, "config", "user.name", "Monalisa Octocat") runGitInDir(t, dir, "remote", "add", "origin", "https://gitlab.com/hubot/bar.git") }, - opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, GitClient: &git.Client{RepoDir: dir}, @@ -774,9 +799,9 @@ func TestPublishRun(t *testing.T) { stubs: func(reg *httpmock.Registry) { stubAllSecureRemote(reg, "octocat", "repo") }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, DryRun: true, @@ -860,9 +885,9 @@ func TestPublishRun(t *testing.T) { }), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, Tag: "v1.0.0", @@ -937,9 +962,9 @@ func TestPublishRun(t *testing.T) { }), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, Tag: "v2.3.5", @@ -968,9 +993,9 @@ func TestPublishRun(t *testing.T) { stubs: func(reg *httpmock.Registry) { stubAllSecureRemote(reg, "monalisa", "skills-repo") }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, Tag: "v1.0.0", // same as stubAllSecureRemote's existing tag @@ -1000,9 +1025,9 @@ func TestPublishRun(t *testing.T) { stubs: func(reg *httpmock.Registry) { stubAllSecureRemote(reg, "monalisa", "skills-repo") }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, GitClient: newTestGitClient(t, map[string]string{ @@ -1027,9 +1052,9 @@ func TestPublishRun(t *testing.T) { Body. `)) }, - opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, } @@ -1051,7 +1076,7 @@ func TestPublishRun(t *testing.T) { `)) }, stubs: func(reg *httpmock.Registry) { - // No topic yet — first GET for diagnostic check + // No topic yet, first GET for diagnostic check reg.Register( httpmock.REST("GET", "repos/monalisa/skills-repo/topics"), httpmock.JSONResponse(map[string]interface{}{"names": []string{}}), @@ -1097,10 +1122,10 @@ func TestPublishRun(t *testing.T) { }), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() confirmCall := 0 - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, Prompter: &prompter.PrompterMock{ @@ -1155,9 +1180,9 @@ func TestPublishRun(t *testing.T) { }), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, Prompter: &prompter.PrompterMock{ @@ -1205,10 +1230,10 @@ func TestPublishRun(t *testing.T) { httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"}), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() confirmCall := 0 - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, Prompter: &prompter.PrompterMock{ @@ -1273,9 +1298,9 @@ func TestPublishRun(t *testing.T) { }), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, Prompter: &prompter.PrompterMock{ diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go index 48ea9f358..abf925275 100644 --- a/pkg/cmd/skills/search/search.go +++ b/pkg/cmd/skills/search/search.go @@ -45,7 +45,7 @@ var SkillSearchFields = []string{ "path", } -type searchOptions struct { +type SearchOptions struct { IO *iostreams.IOStreams HttpClient func() (*http.Client, error) Config func() (gh.Config, error) @@ -61,8 +61,8 @@ type searchOptions struct { } // NewCmdSearch creates the "skills search" command. -func NewCmdSearch(f *cmdutil.Factory, runF func(*searchOptions) error) *cobra.Command { - opts := &searchOptions{ +func NewCmdSearch(f *cmdutil.Factory, runF func(*SearchOptions) error) *cobra.Command { + opts := &SearchOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, Config: f.Config, @@ -71,8 +71,8 @@ func NewCmdSearch(f *cmdutil.Factory, runF func(*searchOptions) error) *cobra.Co } cmd := &cobra.Command{ - Use: "search ", - Short: "Search for skills across GitHub", + Use: "search [flags]", + Short: "Search for skills across GitHub (preview)", Long: heredoc.Doc(` Search across all public GitHub repositories for skills matching a keyword. @@ -188,7 +188,7 @@ func (s skillResult) ExportData(fields []string) map[string]interface{} { return data } -func searchRun(opts *searchOptions) error { +func searchRun(opts *SearchOptions) error { httpClient, err := opts.HttpClient() if err != nil { return err @@ -252,7 +252,7 @@ func searchRun(opts *searchOptions) error { } // noResultsMessage returns an appropriate "no results" message. -func noResultsMessage(opts *searchOptions) string { +func noResultsMessage(opts *SearchOptions) string { if opts.Owner != "" { return fmt.Sprintf("no skills found matching %q from owner %q", opts.Query, opts.Owner) } @@ -328,7 +328,7 @@ func searchByKeyword(client *api.Client, host, queryTerm, owner string, page, li return nil, primaryErr } - // Merge: path-matched → hyphen-matched → owner-matched → primary content. + // Merge: path-matched > hyphen-matched > owner-matched > primary content. var merged []codeSearchItem if pathErr == nil && pathResult != nil { @@ -346,7 +346,7 @@ func searchByKeyword(client *api.Client, host, queryTerm, owner string, page, li } // noResults returns an empty JSON array for exporters or a no-results error. -func noResults(opts *searchOptions, msg string) error { +func noResults(opts *SearchOptions, msg string) error { if opts.Exporter != nil { return opts.Exporter.Write(opts.IO, []skillResult{}) } @@ -433,7 +433,7 @@ func deduplicateByName(skills []skillResult) []skillResult { } // renderResults handles all output modes: JSON, interactive picker, or table. -func renderResults(opts *searchOptions, skills []skillResult, totalPages int) error { +func renderResults(opts *SearchOptions, skills []skillResult, totalPages int) error { if opts.Exporter != nil { return opts.Exporter.Write(opts.IO, skills) } @@ -499,7 +499,7 @@ func renderTable(io *iostreams.IOStreams, skills []skillResult) error { // promptInstall shows a multi-select picker for the user to choose skills // to install from the search results, then runs the install command for each. -func promptInstall(opts *searchOptions, skills []skillResult) error { +func promptInstall(opts *SearchOptions, skills []skillResult) error { fmt.Fprintln(opts.IO.ErrOut) cs := opts.IO.ColorScheme() @@ -589,7 +589,7 @@ func relevanceScore(s skillResult, query string) int { score := 0 // Name match. Normalize spaces to hyphens since skill directory names - // use hyphens as word separators (e.g. query "mcp apps" → "mcp-apps"). + // use hyphens as word separators (e.g. query "mcp apps" > "mcp-apps"). skillLower := strings.ToLower(s.SkillName) if skillLower == term || skillLower == termHyphen { score += 3_000 @@ -825,7 +825,7 @@ func extractSkillName(filePath string) string { return discovery.MatchesSkillPath(filePath) } -// formatStars formats a star count for display (e.g. 1700 → "1.7k"). +// formatStars formats a star count for display (e.g. 1700 > "1.7k"). // TODO kw: Could be swaped for go-humanize. func formatStars(n int) string { if n >= 1000 { diff --git a/pkg/cmd/skills/search/search_test.go b/pkg/cmd/skills/search/search_test.go index 6e35465cd..98d26d146 100644 --- a/pkg/cmd/skills/search/search_test.go +++ b/pkg/cmd/skills/search/search_test.go @@ -23,7 +23,7 @@ func TestSearchRun_UnsupportedHost(t *testing.T) { cfg.AuthenticationFunc = func() gh.AuthConfig { return authCfg } - err := searchRun(&searchOptions{ + err := searchRun(&SearchOptions{ IO: ios, Query: "terraform", Page: 1, @@ -38,33 +38,33 @@ func TestNewCmdSearch(t *testing.T) { tests := []struct { name string args string - wantOpts searchOptions + wantOpts SearchOptions wantErr string }{ { name: "query argument", args: "terraform", - wantOpts: searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + wantOpts: SearchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, }, { name: "with page flag", args: "terraform --page 3", - wantOpts: searchOptions{Query: "terraform", Page: 3, Limit: defaultLimit}, + wantOpts: SearchOptions{Query: "terraform", Page: 3, Limit: defaultLimit}, }, { name: "with limit flag", args: "terraform --limit 5", - wantOpts: searchOptions{Query: "terraform", Page: 1, Limit: 5}, + wantOpts: SearchOptions{Query: "terraform", Page: 1, Limit: 5}, }, { name: "with limit short flag", args: "terraform -L 10", - wantOpts: searchOptions{Query: "terraform", Page: 1, Limit: 10}, + wantOpts: SearchOptions{Query: "terraform", Page: 1, Limit: 10}, }, { name: "with owner flag", args: "terraform --owner hashicorp", - wantOpts: searchOptions{Query: "terraform", Owner: "hashicorp", Page: 1, Limit: defaultLimit}, + wantOpts: SearchOptions{Query: "terraform", Owner: "hashicorp", Page: 1, Limit: defaultLimit}, }, { name: "no arguments", @@ -101,8 +101,8 @@ func TestNewCmdSearch(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { f := &cmdutil.Factory{} - var gotOpts *searchOptions - cmd := NewCmdSearch(f, func(opts *searchOptions) error { + var gotOpts *SearchOptions + cmd := NewCmdSearch(f, func(opts *SearchOptions) error { gotOpts = opts return nil }) @@ -149,7 +149,7 @@ func TestSearchRun(t *testing.T) { tests := []struct { name string - opts *searchOptions + opts *SearchOptions tty bool httpStubs func(*httpmock.Registry) wantStdout string @@ -159,7 +159,7 @@ func TestSearchRun(t *testing.T) { { name: "displays results in non-TTY", tty: false, - opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + opts: &SearchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, httpStubs: func(reg *httpmock.Registry) { stubKeywordSearch(reg, `{"total_count": 1, "incomplete_results": false, "items": [{"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "repository": {"full_name": "github/awesome-skills"}}]}`) }, @@ -168,7 +168,7 @@ func TestSearchRun(t *testing.T) { { name: "deduplicates results", tty: false, - opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + opts: &SearchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, httpStubs: func(reg *httpmock.Registry) { stubKeywordSearch(reg, `{"total_count": 3, "incomplete_results": false, "items": [{"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "repository": {"full_name": "github/awesome-skills"}}, {"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "repository": {"full_name": "github/awesome-skills"}}, {"name": "SKILL.md", "path": "skills/terraform-aws/SKILL.md", "repository": {"full_name": "github/awesome-skills"}}]}`) }, @@ -177,7 +177,7 @@ func TestSearchRun(t *testing.T) { { name: "no results", tty: true, - opts: &searchOptions{Query: "nonexistent", Page: 1, Limit: defaultLimit}, + opts: &SearchOptions{Query: "nonexistent", Page: 1, Limit: defaultLimit}, httpStubs: func(reg *httpmock.Registry) { stubKeywordSearch(reg, emptyCodeResponse) }, @@ -186,7 +186,7 @@ func TestSearchRun(t *testing.T) { { name: "nested skill path", tty: false, - opts: &searchOptions{Query: "my-skill", Page: 1, Limit: defaultLimit}, + opts: &SearchOptions{Query: "my-skill", Page: 1, Limit: defaultLimit}, httpStubs: func(reg *httpmock.Registry) { stubKeywordSearch(reg, `{"total_count": 1, "incomplete_results": false, "items": [{"name": "SKILL.md", "path": "skills/author/my-skill/SKILL.md", "repository": {"full_name": "org/repo"}}]}`) }, @@ -195,7 +195,7 @@ func TestSearchRun(t *testing.T) { { name: "ranks name-matching results first", tty: false, - opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + opts: &SearchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, httpStubs: func(reg *httpmock.Registry) { stubKeywordSearch(reg, `{"total_count": 3, "incomplete_results": false, "items": [ {"name": "SKILL.md", "path": "skills/terraform-deploy/SKILL.md", "repository": {"full_name": "org/repo1"}}, @@ -209,7 +209,7 @@ func TestSearchRun(t *testing.T) { { name: "caps total pages at 1000-result limit", tty: false, - opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + opts: &SearchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, httpStubs: func(reg *httpmock.Registry) { stubKeywordSearch(reg, `{"total_count": 5000, "incomplete_results": false, "items": [{"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "repository": {"full_name": "org/repo"}}]}`) }, @@ -219,7 +219,7 @@ func TestSearchRun(t *testing.T) { { name: "page beyond available results", tty: false, - opts: &searchOptions{Query: "terraform", Page: 999, Limit: defaultLimit}, + opts: &SearchOptions{Query: "terraform", Page: 999, Limit: defaultLimit}, httpStubs: func(reg *httpmock.Registry) { stubKeywordSearch(reg, `{"total_count": 1, "incomplete_results": false, "items": [{"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "repository": {"full_name": "org/repo"}}]}`) }, @@ -228,10 +228,10 @@ func TestSearchRun(t *testing.T) { { name: "json output with selected fields", tty: false, - opts: func() *searchOptions { + opts: func() *SearchOptions { exporter := cmdutil.NewJSONExporter() exporter.SetFields([]string{"repo", "skillName", "stars"}) - return &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit, Exporter: exporter} + return &SearchOptions{Query: "terraform", Page: 1, Limit: defaultLimit, Exporter: exporter} }(), httpStubs: func(reg *httpmock.Registry) { stubKeywordSearch(reg, `{"total_count": 1, "incomplete_results": false, "items": [{"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "repository": {"full_name": "github/awesome-skills"}}]}`) @@ -241,10 +241,10 @@ func TestSearchRun(t *testing.T) { { name: "json output empty results", tty: false, - opts: func() *searchOptions { + opts: func() *SearchOptions { exporter := cmdutil.NewJSONExporter() exporter.SetFields([]string{"repo", "skillName"}) - return &searchOptions{Query: "nonexistent", Page: 1, Limit: defaultLimit, Exporter: exporter} + return &SearchOptions{Query: "nonexistent", Page: 1, Limit: defaultLimit, Exporter: exporter} }(), httpStubs: func(reg *httpmock.Registry) { stubKeywordSearch(reg, emptyCodeResponse) @@ -254,7 +254,7 @@ func TestSearchRun(t *testing.T) { { name: "rate limit error returns friendly message", tty: false, - opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + opts: &SearchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, httpStubs: func(reg *httpmock.Registry) { // All search/code calls return 403 with x-ratelimit-remaining: 0 for range 3 { @@ -272,7 +272,7 @@ func TestSearchRun(t *testing.T) { { name: "HTTP 429 returns rate limit error", tty: false, - opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + opts: &SearchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, httpStubs: func(reg *httpmock.Registry) { for range 3 { reg.Register( @@ -286,7 +286,7 @@ func TestSearchRun(t *testing.T) { { name: "HTTP 403 with Retry-After returns rate limit error", tty: false, - opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + opts: &SearchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, httpStubs: func(reg *httpmock.Registry) { for range 3 { reg.Register( @@ -303,7 +303,7 @@ func TestSearchRun(t *testing.T) { { name: "no results with owner scope", tty: true, - opts: &searchOptions{Query: "nonexistent", Owner: "monalisa", Page: 1, Limit: defaultLimit}, + opts: &SearchOptions{Query: "nonexistent", Owner: "monalisa", Page: 1, Limit: defaultLimit}, httpStubs: func(reg *httpmock.Registry) { // With --owner set, only path + primary searches fire (no owner search). for range 2 { @@ -318,7 +318,7 @@ func TestSearchRun(t *testing.T) { { name: "enriches results with blob descriptions", tty: false, - opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + opts: &SearchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, httpStubs: func(reg *httpmock.Registry) { codeResponse := `{"total_count": 1, "incomplete_results": false, "items": [ {"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "sha": "abc123", diff --git a/pkg/cmd/skills/skills.go b/pkg/cmd/skills/skills.go index 8f9c45faf..2989c52f8 100644 --- a/pkg/cmd/skills/skills.go +++ b/pkg/cmd/skills/skills.go @@ -1,6 +1,7 @@ package skills import ( + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/pkg/cmd/skills/install" "github.com/cli/cli/v2/pkg/cmd/skills/preview" "github.com/cli/cli/v2/pkg/cmd/skills/publish" @@ -13,11 +14,32 @@ import ( // NewCmdSkills returns the top-level "skill" command. func NewCmdSkills(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ - Use: "skill ", - Short: "Install and manage agent skills", - Long: "Install and manage agent skills from GitHub repositories.", + Use: "skill ", + Short: "Install and manage agent skills (preview)", + Long: heredoc.Doc(` + Install and manage agent skills from GitHub repositories. + + Working with agent skills in the GitHub CLI is in preview and + subject to change without notice. + `), Aliases: []string{"skills"}, GroupID: "core", + Example: heredoc.Doc(` + # Search for skills + $ gh skill search terraform + + # Install a skill + $ gh skill install github/awesome-copilot code-review + + # Preview a skill before installing + $ gh skill preview github/awesome-copilot code-review + + # Update all installed skills + $ gh skill update --all + + # Validate skills for publishing + $ gh skill publish --dry-run + `), } cmd.AddCommand(install.NewCmdInstall(f, nil)) diff --git a/pkg/cmd/skills/update/update.go b/pkg/cmd/skills/update/update.go index 11db14a34..766f52515 100644 --- a/pkg/cmd/skills/update/update.go +++ b/pkg/cmd/skills/update/update.go @@ -23,23 +23,20 @@ import ( "github.com/spf13/cobra" ) -// updateOptions holds all dependencies and user-provided flags for the update command. -type updateOptions struct { +// UpdateOptions holds all dependencies and user-provided flags for the update command. +type UpdateOptions struct { IO *iostreams.IOStreams HttpClient func() (*http.Client, error) Config func() (gh.Config, error) Prompter prompter.Prompter GitClient *git.Client - // Arguments - Skills []string // optional: specific skills to update - - // Flags - All bool // --all flag (update without prompting) - Force bool // --force flag (re-download even if SHAs match) - DryRun bool // --dry-run flag (report only, no changes) - Unpin bool // --unpin flag (clear pinned ref and include in update) - Dir string // --dir flag (scan a custom directory) + Skills []string + All bool + Force bool + DryRun bool + Unpin bool + Dir string } // installedSkill represents a locally installed skill parsed from its SKILL.md frontmatter. @@ -66,8 +63,8 @@ type pendingUpdate struct { } // NewCmdUpdate creates the "skills update" command. -func NewCmdUpdate(f *cmdutil.Factory, runF func(*updateOptions) error) *cobra.Command { - opts := &updateOptions{ +func NewCmdUpdate(f *cmdutil.Factory, runF func(*UpdateOptions) error) *cobra.Command { + opts := &UpdateOptions{ IO: f.IOStreams, Prompter: f.Prompter, Config: f.Config, @@ -76,8 +73,8 @@ func NewCmdUpdate(f *cmdutil.Factory, runF func(*updateOptions) error) *cobra.Co } cmd := &cobra.Command{ - Use: "update [...]", - Short: "Update installed skills to their latest versions", + Use: "update [...] [flags]", + Short: "Update installed skills to their latest versions (preview)", Long: heredoc.Doc(` Checks installed skills for available updates by comparing the local tree SHA (from SKILL.md frontmatter) against the remote repository. @@ -142,7 +139,7 @@ func NewCmdUpdate(f *cmdutil.Factory, runF func(*updateOptions) error) *cobra.Co return cmd } -func updateRun(opts *updateOptions) error { +func updateRun(opts *UpdateOptions) error { cs := opts.IO.ColorScheme() canPrompt := opts.IO.CanPrompt() @@ -190,10 +187,23 @@ func updateRun(opts *updateOptions) error { installed = filtered } - for _, s := range installed { - if s.metadataErr != nil { - return fmt.Errorf("skill %s has invalid repository metadata: %w", s.name, s.metadataErr) + // Skip skills with invalid metadata rather than aborting the entire + // update run. One corrupt skill should not prevent updating others. + { + var valid []installedSkill + for _, s := range installed { + if s.metadataErr != nil { + fmt.Fprintf(opts.IO.ErrOut, "%s Skipping %s: invalid repository metadata: %s\n", cs.WarningIcon(), s.name, s.metadataErr) + continue + } + valid = append(valid, s) } + installed = valid + } + + if len(installed) == 0 { + fmt.Fprintf(opts.IO.ErrOut, "No updatable skills found.\n") + return nil } // Prompt for metadata on skills missing it (before starting progress indicator) @@ -205,7 +215,7 @@ func updateRun(opts *updateOptions) error { name string source string // "owner/repo" } - prompted := make(map[string]promptedEntry) // dir → entry + prompted := make(map[string]promptedEntry) // dir > entry for i := range installed { s := &installed[i] if s.owner != "" && s.repo != "" { @@ -327,7 +337,7 @@ func updateRun(opts *updateOptions) error { fmt.Fprintf(opts.IO.ErrOut, "%s %s is pinned to %s (skipped)\n", cs.Muted("⊘"), s.name, s.pinned) } for _, name := range noMeta { - fmt.Fprintf(opts.IO.ErrOut, "%s %s has no GitHub metadata — reinstall to enable updates\n", cs.WarningIcon(), name) + fmt.Fprintf(opts.IO.ErrOut, "%s %s has no GitHub metadata. Reinstall to enable updates\n", cs.WarningIcon(), name) } if len(updates) == 0 { @@ -346,7 +356,7 @@ func updateRun(opts *updateOptions) error { cs.Cyan("•"), u.local.name, u.local.owner, u.local.repo, git.ShortSHA(u.newSHA), discovery.ShortRef(u.resolved.Ref)) } else { - fmt.Fprintf(opts.IO.Out, " %s %s (%s/%s) %s → %s [%s]\n", + fmt.Fprintf(opts.IO.Out, " %s %s (%s/%s) %s > %s [%s]\n", cs.Cyan("•"), u.local.name, u.local.owner, u.local.repo, cs.Muted(git.ShortSHA(u.local.treeSHA)), git.ShortSHA(u.newSHA), discovery.ShortRef(u.resolved.Ref)) diff --git a/pkg/cmd/skills/update/update_test.go b/pkg/cmd/skills/update/update_test.go index b9aabe86a..0c224e9a0 100644 --- a/pkg/cmd/skills/update/update_test.go +++ b/pkg/cmd/skills/update/update_test.go @@ -30,11 +30,11 @@ func TestNewCmdUpdate_Help(t *testing.T) { GitClient: &git.Client{}, } - cmd := NewCmdUpdate(f, func(opts *updateOptions) error { + cmd := NewCmdUpdate(f, func(opts *UpdateOptions) error { return nil }) - assert.Equal(t, "update [...]", cmd.Use) + assert.Equal(t, "update [...] [flags]", cmd.Use) assert.NotEmpty(t, cmd.Short) assert.NotEmpty(t, cmd.Long) assert.NotEmpty(t, cmd.Example) @@ -43,7 +43,7 @@ func TestNewCmdUpdate_Help(t *testing.T) { func TestNewCmdUpdate_Flags(t *testing.T) { ios, _, _, _ := iostreams.Test() f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} - cmd := NewCmdUpdate(f, func(_ *updateOptions) error { return nil }) + cmd := NewCmdUpdate(f, func(_ *UpdateOptions) error { return nil }) flags := []string{"all", "force", "dry-run", "dir", "unpin"} for _, name := range flags { @@ -55,8 +55,8 @@ func TestNewCmdUpdate_ArgsPassedToOptions(t *testing.T) { ios, _, stdout, stderr := iostreams.Test() f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} - var gotOpts *updateOptions - cmd := NewCmdUpdate(f, func(opts *updateOptions) error { + var gotOpts *UpdateOptions + cmd := NewCmdUpdate(f, func(opts *UpdateOptions) error { gotOpts = opts return nil }) @@ -176,7 +176,7 @@ func TestScanInstalledSkills(t *testing.T) { }, { name: "non-existent directory returns nil", - // no setup — dir does not exist + // no setup needed; dir does not exist verify: func(t *testing.T, skills []installedSkill, err error) { t.Helper() require.NoError(t, err) @@ -313,7 +313,7 @@ func TestUpdateRun(t *testing.T) { name string setup func(t *testing.T, dir string) stubs func(reg *httpmock.Registry) - opts func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions + opts func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions verify func(t *testing.T, dir string) wantErr string wantStderr string @@ -349,9 +349,9 @@ func TestUpdateRun(t *testing.T) { httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/commit1"), httpmock.StringResponse(`{"sha": "commit1", "tree": [{"path": "skills/code-review", "type": "tree", "sha": "currentsha"}, {"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "blob1"}, {"path": "skills", "type": "tree", "sha": "treeshaX"}], "truncated": false}`)) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(false) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -365,9 +365,9 @@ func TestUpdateRun(t *testing.T) { { name: "no installed skills", stubs: func(reg *httpmock.Registry) {}, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(false) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -395,9 +395,9 @@ func TestUpdateRun(t *testing.T) { `)), 0o644)) }, stubs: func(reg *httpmock.Registry) {}, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(false) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -427,10 +427,10 @@ func TestUpdateRun(t *testing.T) { `)), 0o644)) }, stubs: func(reg *httpmock.Registry) {}, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(true) ios.SetStderrTTY(true) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -457,10 +457,10 @@ func TestUpdateRun(t *testing.T) { `)), 0o644)) }, stubs: func(reg *httpmock.Registry) {}, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(false) ios.SetStdinTTY(false) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -502,9 +502,9 @@ func TestUpdateRun(t *testing.T) { httpmock.StringResponse(fmt.Sprintf(`{"sha": "commitsha123", "tree": [{"path": "skills/monalisa-skill/SKILL.md", "type": "blob", "sha": "blobsha1"}, {"path": "skills/monalisa-skill", "type": "tree", "sha": "abc123def456"}, {"path": "skills", "type": "tree", "sha": "treeshaX"}], "truncated": false}`)), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(false) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -546,10 +546,10 @@ func TestUpdateRun(t *testing.T) { httpmock.StringResponse(`{"sha": "newcommit456", "tree": [{"path": "skills/hubot-skill/SKILL.md", "type": "blob", "sha": "blobsha2"}, {"path": "skills/hubot-skill", "type": "tree", "sha": "newsha456"}, {"path": "skills", "type": "tree", "sha": "treeshaY"}], "truncated": false}`), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(true) ios.SetStderrTTY(true) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -594,10 +594,10 @@ func TestUpdateRun(t *testing.T) { httpmock.StringResponse(`{"sha": "newcommit456", "tree": [{"path": "skills/hubot-skill/SKILL.md", "type": "blob", "sha": "blobsha2"}, {"path": "skills/hubot-skill", "type": "tree", "sha": "newsha456"}, {"path": "skills", "type": "tree", "sha": "treeshaY"}], "truncated": false}`), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(false) ios.SetStdinTTY(false) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -647,9 +647,9 @@ func TestUpdateRun(t *testing.T) { httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, "IyBDb2RlIFJldmlldyBVcGRhdGVk"))) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(false) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -708,9 +708,9 @@ func TestUpdateRun(t *testing.T) { httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, "IyBOYW1lc3BhY2VkIFNraWxsIFVwZGF0ZWQ="))) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(false) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -768,9 +768,9 @@ func TestUpdateRun(t *testing.T) { httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/newblob1"), httpmock.StatusStringResponse(500, "server error")) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(false) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -828,11 +828,11 @@ func TestUpdateRun(t *testing.T) { httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, "IyBDb2RlIFJldmlldyBVcGRhdGVk"))) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(true) ios.SetStdinTTY(true) ios.SetStderrTTY(true) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -883,11 +883,11 @@ func TestUpdateRun(t *testing.T) { httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(true) ios.SetStdinTTY(true) ios.SetStderrTTY(true) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -919,11 +919,11 @@ func TestUpdateRun(t *testing.T) { `)), 0o644)) }, stubs: func(reg *httpmock.Registry) {}, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(true) ios.SetStdinTTY(true) ios.SetStderrTTY(true) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -974,11 +974,11 @@ func TestUpdateRun(t *testing.T) { httpmock.StringResponse(fmt.Sprintf(`{"sha": "blob1", "encoding": "base64", "content": "%s"}`, "IyBNYW51YWwgU2tpbGwgVXBkYXRlZA=="))) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(true) ios.SetStdinTTY(true) ios.SetStderrTTY(true) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -1044,9 +1044,9 @@ func TestUpdateRun(t *testing.T) { httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, "IyBVbnBpbm5lZCBhbmQgVXBkYXRlZA=="))) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(false) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -1084,10 +1084,10 @@ func TestUpdateRun(t *testing.T) { `)), 0o644)) }, stubs: func(reg *httpmock.Registry) {}, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(true) ios.SetStderrTTY(true) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -1130,10 +1130,10 @@ func TestUpdateRun(t *testing.T) { httpmock.REST("GET", "repos/octocat/hubot-skills/git/trees/newcommit789"), httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/pinned-skill/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/pinned-skill", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(true) ios.SetStderrTTY(true) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { From 6c7743eaf15d51dcfb0e858974dabd023f1b61cf Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 15 Apr 2026 16:41:09 +0200 Subject: [PATCH 12/27] fix: align relevanceScore comments with implementation and fix typo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update comment in relevanceScore() from '10 000 points' to '3 000 points' to match the actual implementation value of 3_000 - Update corresponding test comment for consistency - Fix typo: 'swaped' → 'swapped' in formatStars comment Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/skills/search/search.go | 4 ++-- pkg/cmd/skills/search/search_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go index abf925275..3d3114f3d 100644 --- a/pkg/cmd/skills/search/search.go +++ b/pkg/cmd/skills/search/search.go @@ -579,7 +579,7 @@ func promptInstall(opts *SearchOptions, skills []skillResult) error { // relevanceScore computes a numeric ranking score for a search result. // Higher scores rank first. Signals (in priority order): -// - Exact skill name match (10 000 points) +// - Exact skill name match (3 000 points) // - Partial skill name match (1 000 points) // - Description contains query (100 points) // - Repository stars (sqrt bonus, ~2 400 for 6k stars) @@ -826,7 +826,7 @@ func extractSkillName(filePath string) string { } // formatStars formats a star count for display (e.g. 1700 > "1.7k"). -// TODO kw: Could be swaped for go-humanize. +// TODO kw: Could be swapped for go-humanize. func formatStars(n int) string { if n >= 1000 { return fmt.Sprintf("%.1fk", float64(n)/1000) diff --git a/pkg/cmd/skills/search/search_test.go b/pkg/cmd/skills/search/search_test.go index 98d26d146..b3b177c64 100644 --- a/pkg/cmd/skills/search/search_test.go +++ b/pkg/cmd/skills/search/search_test.go @@ -456,7 +456,7 @@ func TestRankByRelevance(t *testing.T) { rankByRelevance(skills, "terraform") - // Exact name match scores highest (10 000), then partial name (1 000), + // Exact name match scores highest (3 000), then partial name (1 000), // then description match (100), then body-only (0). assert.Equal(t, "terraform", skills[0].SkillName) assert.Equal(t, "terraform-plan", skills[1].SkillName) From a6f6ab330f4e21b8ae2e87c3409c79a421fdebd1 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 15 Apr 2026 16:53:27 +0200 Subject: [PATCH 13/27] fix: enforce size cap on first preview file, surface corrupted skills, fail on path traversal - preview: remove 'fetched > 0' guard so the 512KB size cap applies uniformly to all files including the first - update: return skills with corrupted YAML frontmatter with metadataErr set instead of silently dropping them from scan results - installer: fail installation when a path traversal is detected in remote or local skill files instead of silently skipping Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/skills/installer/installer.go | 4 ++-- internal/skills/installer/installer_test.go | 14 ++++++++------ pkg/cmd/skills/preview/preview.go | 2 +- pkg/cmd/skills/update/update.go | 8 +++++++- pkg/cmd/skills/update/update_test.go | 4 +++- 5 files changed, 21 insertions(+), 11 deletions(-) diff --git a/internal/skills/installer/installer.go b/internal/skills/installer/installer.go index 0ac9e182c..e27d35f5b 100644 --- a/internal/skills/installer/installer.go +++ b/internal/skills/installer/installer.go @@ -216,7 +216,7 @@ func installLocalSkill(sourceRoot string, skill discovery.Skill, baseDir string) if err != nil { var traversalErr safepaths.PathTraversalError if errors.As(err, &traversalErr) { - return nil + return fmt.Errorf("blocked path traversal in %q", relPath) } return fmt.Errorf("could not resolve destination path: %w", err) } @@ -273,7 +273,7 @@ func installSkill(opts *Options, skill discovery.Skill, baseDir string) error { if err != nil { var traversalErr safepaths.PathTraversalError if errors.As(err, &traversalErr) { - continue + return fmt.Errorf("blocked path traversal in %q", relPath) } return fmt.Errorf("could not resolve destination path: %w", err) } diff --git a/internal/skills/installer/installer_test.go b/internal/skills/installer/installer_test.go index 6334add85..e05a3541e 100644 --- a/internal/skills/installer/installer_test.go +++ b/internal/skills/installer/installer_test.go @@ -253,7 +253,7 @@ func TestInstallSkill(t *testing.T) { }, }, { - name: "skips path traversal from malicious tree", + name: "fails on path traversal from malicious tree", skill: discovery.Skill{Name: "code-review", Path: "skills/code-review", TreeSHA: "tree123"}, stubs: func(reg *httpmock.Registry) { reg.Register( @@ -280,10 +280,7 @@ func TestInstallSkill(t *testing.T) { }, verify: func(t *testing.T, destDir string) { t.Helper() - _, err := os.Stat(filepath.Join(destDir, "code-review", "SKILL.md")) - assert.NoError(t, err) - - _, err = os.Stat(filepath.Join(destDir, "..", "etc", "passwd")) + _, err := os.Stat(filepath.Join(destDir, "..", "etc", "passwd")) assert.True(t, os.IsNotExist(err), "traversal path should not be written") }, }, @@ -305,7 +302,12 @@ func TestInstallSkill(t *testing.T) { } err := installSkill(opts, tt.skill, destDir) - require.NoError(t, err) + if tt.name == "fails on path traversal from malicious tree" { + require.Error(t, err) + assert.Contains(t, err.Error(), "blocked path traversal") + } else { + require.NoError(t, err) + } tt.verify(t, destDir) }) } diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index 270912478..319288f17 100644 --- a/pkg/cmd/skills/preview/preview.go +++ b/pkg/cmd/skills/preview/preview.go @@ -216,7 +216,7 @@ func renderAllFiles(opts *PreviewOptions, cs *iostreams.ColorScheme, skill disco fmt.Fprintf(out, "\n%s\n", cs.Muted(fmt.Sprintf("(skipped remaining files, showing first %d)", maxFiles))) break } - if totalBytes+f.Size > maxTotalBytes && fetched > 0 { + if totalBytes+f.Size > maxTotalBytes { fmt.Fprintf(out, "\n%s\n", cs.Muted("(skipped remaining files, size limit reached)")) break } diff --git a/pkg/cmd/skills/update/update.go b/pkg/cmd/skills/update/update.go index 766f52515..ad7c647d5 100644 --- a/pkg/cmd/skills/update/update.go +++ b/pkg/cmd/skills/update/update.go @@ -510,7 +510,13 @@ func scanInstalledSkills(skillsDir string, host *registry.AgentHost, scope regis func parseInstalledSkill(data []byte, name, dir string, host *registry.AgentHost, scope registry.Scope) (installedSkill, bool) { result, err := frontmatter.Parse(string(data)) if err != nil { - return installedSkill{}, false + return installedSkill{ + name: name, + dir: dir, + host: host, + scope: scope, + metadataErr: fmt.Errorf("invalid SKILL.md: %w", err), + }, true } s := installedSkill{ diff --git a/pkg/cmd/skills/update/update_test.go b/pkg/cmd/skills/update/update_test.go index 0c224e9a0..86fdcaa80 100644 --- a/pkg/cmd/skills/update/update_test.go +++ b/pkg/cmd/skills/update/update_test.go @@ -199,7 +199,9 @@ func TestScanInstalledSkills(t *testing.T) { verify: func(t *testing.T, skills []installedSkill, err error) { t.Helper() require.NoError(t, err) - assert.Len(t, skills, 0) + require.Len(t, skills, 1) + assert.Equal(t, "corrupt", skills[0].name) + assert.ErrorContains(t, skills[0].metadataErr, "invalid SKILL.md") }, }, } From 1d5c74a83876524cd55b406c6c6ac0b1eb34735d Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 15 Apr 2026 22:56:06 +0200 Subject: [PATCH 14/27] fix: use target directory remotes in skills publish When a directory argument is provided to `gh skill publish`, the remote detection now correctly uses the target directory's git remotes instead of the current working directory's remotes. Previously, `detectGitHubRemote` used the factory-provided git client which pointed to the CWD. This meant that running `gh skill publish /path/to/repo-bar` from inside repo-foo would detect repo-foo's remotes and potentially create the release on the wrong repo. The fix copies the git client and sets `RepoDir` to the target directory, matching the pattern already used by `detectMissingRepoDiagnostic` and `checkInstalledSkillDirs`. Co-authored-by: BagToad <47394200+BagToad@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../skills/skills-publish-dir-remote.txtar | 58 ++++ pkg/cmd/skills/publish/publish.go | 16 +- pkg/cmd/skills/publish/publish_test.go | 323 +++++++++++------- 3 files changed, 277 insertions(+), 120 deletions(-) create mode 100644 acceptance/testdata/skills/skills-publish-dir-remote.txtar diff --git a/acceptance/testdata/skills/skills-publish-dir-remote.txtar b/acceptance/testdata/skills/skills-publish-dir-remote.txtar new file mode 100644 index 000000000..8f833a76c --- /dev/null +++ b/acceptance/testdata/skills/skills-publish-dir-remote.txtar @@ -0,0 +1,58 @@ +# When a directory argument is provided to `gh skill publish --dry-run`, +# the remote detection must use the target directory's git remotes, +# not the current working directory's remotes. +# +# This test creates two separate git repos: +# - cwd-repo (the working directory) with remote pointing to owner/cwd-repo +# - target-repo (the dir argument) with remote pointing to owner/target-repo +# +# If the bug is present, the command would detect cwd-repo's remote instead of +# target-repo's remote. + +# Set up credential helper +exec gh auth setup-git + +# Create two test repos on GitHub +exec gh repo create $ORG/$SCRIPT_NAME-cwd-$RANDOM_STRING --private --add-readme +defer gh repo delete --yes $ORG/$SCRIPT_NAME-cwd-$RANDOM_STRING + +exec gh repo create $ORG/$SCRIPT_NAME-target-$RANDOM_STRING --private --add-readme +defer gh repo delete --yes $ORG/$SCRIPT_NAME-target-$RANDOM_STRING + +# Clone both repos +exec gh repo clone $ORG/$SCRIPT_NAME-cwd-$RANDOM_STRING cwd-repo +exec gh repo clone $ORG/$SCRIPT_NAME-target-$RANDOM_STRING target-repo + +# Add a skill to the target repo only +mkdir target-repo/skills/hello-world +cp $WORK/skill.md target-repo/skills/hello-world/SKILL.md +exec git -C $WORK/target-repo add -A +exec git -C $WORK/target-repo commit -m 'Add test skill' +exec git -C $WORK/target-repo push origin main + +# Run publish dry-run from cwd-repo, pointing at target-repo +cd cwd-repo +exec gh skill publish --dry-run $WORK/target-repo + +# Verify the output references the target repo, not the cwd repo +stdout 'hello-world' + +# Publish with a tag from within cwd-repo, targeting target-repo +exec gh skill publish --tag v0.1.0 $WORK/target-repo + +# Verify the release was created on the TARGET repo, not the cwd repo +exec gh release view v0.1.0 --repo $ORG/$SCRIPT_NAME-target-$RANDOM_STRING +stdout 'v0.1.0' + +# Verify NO release was created on the cwd repo +! exec gh release view v0.1.0 --repo $ORG/$SCRIPT_NAME-cwd-$RANDOM_STRING + +-- skill.md -- +--- +name: hello-world +description: A test skill that greets the user. +--- + +# Hello World + +Greet the user warmly. diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index 82202514b..e178ae535 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -338,7 +338,7 @@ func publishRun(opts *PublishOptions) error { diagnostics = append(diagnostics, installedDirDiags...) // Remote repository checks (best-effort) - repoInfo, remoteErr := detectGitHubRemote(opts.GitClient) + repoInfo, remoteErr := detectGitHubRemote(opts.GitClient, dir) if remoteErr != nil { return remoteErr } @@ -867,14 +867,18 @@ func suggestNextTag(latest string) string { return fmt.Sprintf("%s%s.%s.%d", prefix, major, minor, patch+1) } -// detectGitHubRemote attempts to detect the GitHub owner/repo from git remotes. -func detectGitHubRemote(gitClient *git.Client) (ghrepo.Interface, error) { +// detectGitHubRemote attempts to detect the GitHub owner/repo from git remotes +// in the given directory. +func detectGitHubRemote(gitClient *git.Client, dir string) (ghrepo.Interface, error) { if gitClient == nil { return nil, nil } + dirClient := gitClient.Copy() + dirClient.RepoDir = dir + // Try origin first - if url, err := gitClient.RemoteURL(context.Background(), "origin"); err == nil { + if url, err := dirClient.RemoteURL(context.Background(), "origin"); err == nil { repo, parseErr := parseGitHubURL(url) if parseErr != nil { return nil, parseErr @@ -885,7 +889,7 @@ func detectGitHubRemote(gitClient *git.Client) (ghrepo.Interface, error) { } // Fall back to any remote that points to GitHub - remotes, err := gitClient.Remotes(context.Background()) + remotes, err := dirClient.Remotes(context.Background()) if err != nil { return nil, nil //nolint:nilerr // failing to list remotes is not an error; it just means no repo detected } @@ -893,7 +897,7 @@ func detectGitHubRemote(gitClient *git.Client) (ghrepo.Interface, error) { if r.Name == "origin" { continue } - if url, err := gitClient.RemoteURL(context.Background(), r.Name); err == nil { + if url, err := dirClient.RemoteURL(context.Background(), r.Name); err == nil { repo, parseErr := parseGitHubURL(url) if parseErr != nil { return nil, parseErr diff --git a/pkg/cmd/skills/publish/publish_test.go b/pkg/cmd/skills/publish/publish_test.go index fdcaa6631..4bfd05981 100644 --- a/pkg/cmd/skills/publish/publish_test.go +++ b/pkg/cmd/skills/publish/publish_test.go @@ -23,22 +23,22 @@ import ( func newTestGitClient(t *testing.T, remoteURLs map[string]string) *git.Client { t.Helper() dir := t.TempDir() - runGit := func(args ...string) { - t.Helper() - cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) - cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+dir) - out, err := cmd.CombinedOutput() - require.NoError(t, err, "git %v: %s", args, out) - } - runGit("init", "--initial-branch=main") - runGit("config", "user.email", "monalisa@github.com") - runGit("config", "user.name", "Monalisa Octocat") - for name, url := range remoteURLs { - runGit("remote", "add", name, url) - } + initGitRepo(t, dir, remoteURLs) return &git.Client{RepoDir: dir} } +// initGitRepo initializes a git repo in the given directory and adds remotes. +// Use this when the git repo must live in the same directory as the skill files. +func initGitRepo(t *testing.T, dir string, remoteURLs map[string]string) { + t.Helper() + runGitInDir(t, dir, "init", "--initial-branch=main") + runGitInDir(t, dir, "config", "user.email", "monalisa@github.com") + runGitInDir(t, dir, "config", "user.name", "Monalisa Octocat") + for name, url := range remoteURLs { + runGitInDir(t, dir, "remote", "add", name, url) + } +} + // stubAllSecureRemote registers the standard stubs for a fully-configured remote // repo (topics, tags, rulesets, security) so publishRun skips all remote warnings. func stubAllSecureRemote(reg *httpmock.Registry, owner, repo string) { @@ -151,10 +151,11 @@ func TestPublishRun_UnsupportedHost(t *testing.T) { `)) ios, _, _, _ := iostreams.Test() + initGitRepo(t, dir, map[string]string{"origin": "https://github.com/monalisa/skills-repo.git"}) err := publishRun(&PublishOptions{ IO: ios, Dir: dir, - GitClient: newTestGitClient(t, map[string]string{"origin": "https://github.com/monalisa/skills-repo.git"}), + GitClient: &git.Client{}, client: api.NewClientFromHTTP(&http.Client{}), host: "acme.ghes.com", }) @@ -270,15 +271,16 @@ func TestPublishRun(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) return &PublishOptions{ - IO: ios, - Dir: dir, - DryRun: true, - GitClient: newTestGitClient(t, map[string]string{ - "origin": "https://github.com/monalisa/skills-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", + IO: ios, + Dir: dir, + DryRun: true, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", } }, wantStdout: "1 skill(s) validated successfully", @@ -322,6 +324,9 @@ func TestPublishRun(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) return &PublishOptions{ IO: ios, Dir: dir, @@ -329,11 +334,9 @@ func TestPublishRun(t *testing.T) { Prompter: &prompter.PrompterMock{ ConfirmFunc: func(msg string, def bool) (bool, error) { return true, nil }, }, - GitClient: newTestGitClient(t, map[string]string{ - "origin": "https://github.com/monalisa/skills-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", } }, wantStdout: "Published v1.0.1", @@ -475,14 +478,15 @@ func TestPublishRun(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/octocat/secure-repo.git", + }) return &PublishOptions{ - IO: ios, - Dir: dir, - GitClient: newTestGitClient(t, map[string]string{ - "origin": "https://github.com/octocat/secure-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", + IO: ios, + Dir: dir, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", } }, wantStdout: "secret scanning is not enabled", @@ -527,14 +531,15 @@ func TestPublishRun(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/octocat/tag-repo.git", + }) return &PublishOptions{ - IO: ios, - Dir: dir, - GitClient: newTestGitClient(t, map[string]string{ - "origin": "https://github.com/octocat/tag-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", + IO: ios, + Dir: dir, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", } }, wantStdout: "tag protection", @@ -589,15 +594,16 @@ func TestPublishRun(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/octocat/code-repo.git", + }) return &PublishOptions{ - IO: ios, - Dir: dir, - DryRun: true, - GitClient: newTestGitClient(t, map[string]string{ - "origin": "https://github.com/octocat/code-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", + IO: ios, + Dir: dir, + DryRun: true, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", } }, wantStderr: "code scanning", @@ -653,15 +659,16 @@ func TestPublishRun(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/octocat/dep-repo.git", + }) return &PublishOptions{ - IO: ios, - Dir: dir, - DryRun: true, - GitClient: newTestGitClient(t, map[string]string{ - "origin": "https://github.com/octocat/dep-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", + IO: ios, + Dir: dir, + DryRun: true, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", } }, wantStderr: "Dependabot", @@ -801,16 +808,17 @@ func TestPublishRun(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://gitlab.com/hubot/bar.git", + "upstream": "git@github.com:octocat/repo.git", + }) return &PublishOptions{ - IO: ios, - Dir: dir, - DryRun: true, - GitClient: newTestGitClient(t, map[string]string{ - "origin": "https://gitlab.com/hubot/bar.git", - "upstream": "git@github.com:octocat/repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", + IO: ios, + Dir: dir, + DryRun: true, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", } }, wantStderr: "octocat/repo", @@ -887,6 +895,9 @@ func TestPublishRun(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) return &PublishOptions{ IO: ios, Dir: dir, @@ -894,11 +905,9 @@ func TestPublishRun(t *testing.T) { Prompter: &prompter.PrompterMock{ ConfirmFunc: func(msg string, def bool) (bool, error) { return true, nil }, }, - GitClient: newTestGitClient(t, map[string]string{ - "origin": "https://github.com/monalisa/skills-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", } }, wantStdout: "Added \"agent-skills\" topic", @@ -964,15 +973,16 @@ func TestPublishRun(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) return &PublishOptions{ - IO: ios, - Dir: dir, - Tag: "v2.3.5", - GitClient: newTestGitClient(t, map[string]string{ - "origin": "https://github.com/monalisa/skills-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", + IO: ios, + Dir: dir, + Tag: "v2.3.5", + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", } }, wantStdout: "Published v2.3.5", @@ -995,15 +1005,16 @@ func TestPublishRun(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) return &PublishOptions{ - IO: ios, - Dir: dir, - Tag: "v1.0.0", // same as stubAllSecureRemote's existing tag - GitClient: newTestGitClient(t, map[string]string{ - "origin": "https://github.com/monalisa/skills-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", + IO: ios, + Dir: dir, + Tag: "v1.0.0", // same as stubAllSecureRemote's existing tag + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", } }, wantErr: "tag v1.0.0 already exists", @@ -1027,14 +1038,15 @@ func TestPublishRun(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) return &PublishOptions{ - IO: ios, - Dir: dir, - GitClient: newTestGitClient(t, map[string]string{ - "origin": "https://github.com/monalisa/skills-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", + IO: ios, + Dir: dir, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", } }, wantStdout: "ok", @@ -1125,6 +1137,9 @@ func TestPublishRun(t *testing.T) { opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() confirmCall := 0 + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) return &PublishOptions{ IO: ios, Dir: dir, @@ -1140,11 +1155,9 @@ func TestPublishRun(t *testing.T) { return "v1.0.0", nil // accept suggested tag }, }, - GitClient: newTestGitClient(t, map[string]string{ - "origin": "https://github.com/monalisa/skills-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", } }, wantStdout: "Published v1.0.0", @@ -1182,6 +1195,9 @@ func TestPublishRun(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) return &PublishOptions{ IO: ios, Dir: dir, @@ -1196,11 +1212,9 @@ func TestPublishRun(t *testing.T) { return "beta-1", nil }, }, - GitClient: newTestGitClient(t, map[string]string{ - "origin": "https://github.com/monalisa/skills-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", } }, wantStdout: "Published beta-1", @@ -1233,6 +1247,9 @@ func TestPublishRun(t *testing.T) { opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() confirmCall := 0 + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) return &PublishOptions{ IO: ios, Dir: dir, @@ -1251,11 +1268,9 @@ func TestPublishRun(t *testing.T) { return "v1.0.1", nil }, }, - GitClient: newTestGitClient(t, map[string]string{ - "origin": "https://github.com/monalisa/skills-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", } }, wantErr: "CancelError", @@ -1300,6 +1315,9 @@ func TestPublishRun(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) return &PublishOptions{ IO: ios, Dir: dir, @@ -1314,11 +1332,9 @@ func TestPublishRun(t *testing.T) { return "v1.0.1", nil }, }, - GitClient: newTestGitClient(t, map[string]string{ - "origin": "https://github.com/monalisa/skills-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", } }, wantStdout: "Enabled immutable releases", @@ -1363,6 +1379,85 @@ func TestPublishRun(t *testing.T) { } } +func TestDetectGitHubRemote_UsesDir(t *testing.T) { + // Create two separate git repos: "cwd-repo" simulates the working directory + // and "target-repo" simulates the directory argument passed to publish. + cwdRepo := t.TempDir() + initGitRepo(t, cwdRepo, map[string]string{ + "origin": "https://github.com/monalisa/cwd-repo.git", + }) + + targetRepo := t.TempDir() + initGitRepo(t, targetRepo, map[string]string{ + "origin": "https://github.com/monalisa/target-repo.git", + }) + + // gitClient points at cwd-repo (simulating factory-provided client) + gitClient := &git.Client{RepoDir: cwdRepo} + + // detectGitHubRemote should use targetRepo's remotes, not cwdRepo's + repo, err := detectGitHubRemote(gitClient, targetRepo) + require.NoError(t, err) + require.NotNil(t, repo) + assert.Equal(t, "monalisa", repo.RepoOwner()) + assert.Equal(t, "target-repo", repo.RepoName()) +} + +func TestPublishRun_DirArgUsesTargetRemote(t *testing.T) { + // Regression test: when a directory argument is provided, remote detection + // must use that directory's git remotes, not the factory client's directory. + // + // Scenario: + // 1. User is in cwd-repo (has remote → monalisa/cwd-repo) + // 2. User runs: gh skill publish /path/to/target-repo + // 3. target-repo has remote → monalisa/target-repo + // 4. API calls must go to target-repo, NOT cwd-repo + + cwdRepo := t.TempDir() + initGitRepo(t, cwdRepo, map[string]string{ + "origin": "https://github.com/monalisa/cwd-repo.git", + }) + + targetRepo := t.TempDir() + initGitRepo(t, targetRepo, map[string]string{ + "origin": "https://github.com/monalisa/target-repo.git", + }) + + writeSkill(t, targetRepo, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A test skill + license: MIT + --- + Body text. + `)) + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + + // Stub API calls for target-repo (the correct repo). + // If the bug is present, these stubs won't be called because the code + // would try to hit cwd-repo endpoints instead, and reg.Verify would fail. + stubAllSecureRemote(reg, "monalisa", "target-repo") + + err := publishRun(&PublishOptions{ + IO: ios, + Dir: targetRepo, + DryRun: true, + GitClient: &git.Client{RepoDir: cwdRepo}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + }) + + require.NoError(t, err) + assert.Contains(t, stdout.String(), "1 skill(s) validated successfully") +} + // writeSkill creates skills//SKILL.md with the given content. func writeSkill(t *testing.T, dir, name, content string) { t.Helper() From 92e40eabea2696787ef0ede6d11889611348dbc1 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 15 Apr 2026 23:01:09 +0200 Subject: [PATCH 15/27] fix: preserve namespace in skills search deduplication Skills with the same name but different namespaces (e.g. skills/kynan/commit and skills/will/commit) were being collapsed into a single search result because extractSkillName discarded the namespace. This also caused deduplicateByName to cap results across different namespaces as if they were the same skill. Changes: - Add MatchSkillPath to discovery package returning both name and namespace (the existing MatchesSkillPath is kept for compat) - Add Namespace field to skillResult in search - Fix deduplicateResults to use repo/namespace/name as the dedup key - Fix deduplicateByName to cap by namespace-qualified name - Update table, prompt, and JSON output to show qualified names - Use skill path for install subprocess when namespace is present, ensuring unambiguous install of namespaced skills - Add namespace to --json fields and relevance scoring/filtering - Add unit tests for namespace dedup, qualified names, and filtering - Add acceptance test for namespaced skill search and install Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../skills/skills-search-namespaced.txtar | 60 +++++++++ internal/skills/discovery/discovery.go | 12 ++ internal/skills/discovery/discovery_test.go | 24 ++++ pkg/cmd/skills/search/search.go | 70 +++++++--- pkg/cmd/skills/search/search_test.go | 123 +++++++++++++++--- 5 files changed, 254 insertions(+), 35 deletions(-) create mode 100644 acceptance/testdata/skills/skills-search-namespaced.txtar diff --git a/acceptance/testdata/skills/skills-search-namespaced.txtar b/acceptance/testdata/skills/skills-search-namespaced.txtar new file mode 100644 index 000000000..e0fb888cb --- /dev/null +++ b/acceptance/testdata/skills/skills-search-namespaced.txtar @@ -0,0 +1,60 @@ +# Two namespaced skills with the same base name in the same repo should +# both appear in search results and be independently installable. + +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repo with two namespaced skills that share the name "deploy" +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --public --add-readme +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING +cd $SCRIPT_NAME-$RANDOM_STRING + +mkdir -p skills/alice/deploy +mkdir -p skills/bob/deploy +cp $WORK/alice-skill.md skills/alice/deploy/SKILL.md +cp $WORK/bob-skill.md skills/bob/deploy/SKILL.md + +exec git add -A +exec git commit -m 'Add namespaced skills' +exec git push origin main + +# Publish so the skills are discoverable +exec gh skill publish --tag v1.0.0 + +# Install alice's deploy skill using the full path to disambiguate +exec gh skill install $ORG/$SCRIPT_NAME-$RANDOM_STRING skills/alice/deploy --scope user --force +stdout 'Installed alice/deploy' + +# Install bob's deploy skill using the full path +exec gh skill install $ORG/$SCRIPT_NAME-$RANDOM_STRING skills/bob/deploy --scope user --force +stdout 'Installed bob/deploy' + +# Verify both were installed to separate directories +exists $HOME/.copilot/skills/alice/deploy/SKILL.md +exists $HOME/.copilot/skills/bob/deploy/SKILL.md + +# Verify each has the correct content +grep 'Alice' $HOME/.copilot/skills/alice/deploy/SKILL.md +grep 'Bob' $HOME/.copilot/skills/bob/deploy/SKILL.md + +-- alice-skill.md -- +--- +name: deploy +description: Alice's deployment skill +--- + +# Deploy by Alice + +Deploys infrastructure using Alice's conventions. + +-- bob-skill.md -- +--- +name: deploy +description: Bob's deployment skill +--- + +# Deploy by Bob + +Deploys infrastructure using Bob's conventions. diff --git a/internal/skills/discovery/discovery.go b/internal/skills/discovery/discovery.go index 84f2aa596..6608bf24a 100644 --- a/internal/skills/discovery/discovery.go +++ b/internal/skills/discovery/discovery.go @@ -316,6 +316,18 @@ func MatchesSkillPath(filePath string) string { 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" { diff --git a/internal/skills/discovery/discovery_test.go b/internal/skills/discovery/discovery_test.go index 2de7ef683..fa50900f0 100644 --- a/internal/skills/discovery/discovery_test.go +++ b/internal/skills/discovery/discovery_test.go @@ -898,6 +898,30 @@ func TestMatchesSkillPath(t *testing.T) { } } +func TestMatchSkillPath(t *testing.T) { + tests := []struct { + testName string + path string + wantName string + wantNamespace string + }{ + {testName: "skills convention", path: "skills/code-review/SKILL.md", wantName: "code-review", wantNamespace: ""}, + {testName: "namespaced convention", path: "skills/monalisa/issue-triage/SKILL.md", wantName: "issue-triage", wantNamespace: "monalisa"}, + {testName: "plugins convention", path: "plugins/hubot/skills/pr-summary/SKILL.md", wantName: "pr-summary", wantNamespace: "hubot"}, + {testName: "non-skill file", path: "README.md", wantName: "", wantNamespace: ""}, + {testName: "same name different namespace 1", path: "skills/kynan/commit/SKILL.md", wantName: "commit", wantNamespace: "kynan"}, + {testName: "same name different namespace 2", path: "skills/will/commit/SKILL.md", wantName: "commit", wantNamespace: "will"}, + {testName: "root convention", path: "my-skill/SKILL.md", wantName: "my-skill", wantNamespace: ""}, + } + for _, tt := range tests { + t.Run(tt.testName, func(t *testing.T) { + name, namespace := MatchSkillPath(tt.path) + assert.Equal(t, tt.wantName, name) + assert.Equal(t, tt.wantNamespace, namespace) + }) + } +} + func TestDiscoverSkillFiles(t *testing.T) { tests := []struct { name string diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go index 3d3114f3d..836fa3de5 100644 --- a/pkg/cmd/skills/search/search.go +++ b/pkg/cmd/skills/search/search.go @@ -40,6 +40,7 @@ const ( var SkillSearchFields = []string{ "repo", "skillName", + "namespace", "description", "stars", "path", @@ -162,12 +163,22 @@ type skillResult struct { Owner string // parsed from Repo RepoName string // parsed from Repo SkillName string + Namespace string // author/scope prefix for namespaced skills Description string Path string // original file path (e.g. skills/terraform/SKILL.md) BlobSHA string Stars int // repository stargazer count } +// qualifiedName returns the namespace-qualified skill name (e.g. "author/skill") +// or just the skill name if there is no namespace. +func (s skillResult) qualifiedName() string { + if s.Namespace != "" { + return s.Namespace + "/" + s.SkillName + } + return s.SkillName +} + // ExportData implements cmdutil.exportable for --json output. func (s skillResult) ExportData(fields []string) map[string]interface{} { data := map[string]interface{}{} @@ -176,7 +187,9 @@ func (s skillResult) ExportData(fields []string) map[string]interface{} { case "repo": data[f] = s.Repo case "skillName": - data[f] = s.SkillName + data[f] = s.qualifiedName() + case "namespace": + data[f] = s.Namespace case "description": data[f] = s.Description case "stars": @@ -412,17 +425,18 @@ func paginate(skills []skillResult, page, limit int) ([]skillResult, int) { return skills[start:end], totalPages } -// deduplicateByName caps the number of results with the same skill name. -// Since results are pre-sorted by relevance score, the first occurrences +// deduplicateByName caps the number of results with the same qualified skill +// name. Since results are pre-sorted by relevance score, the first occurrences // are the best instances. This prevents aggregator repos (which copy // popular skills verbatim) from flooding results while still showing -// a few alternative sources. +// a few alternative sources. Namespaced skills (e.g. "author/skill") are +// treated as distinct from bare names. func deduplicateByName(skills []skillResult) []skillResult { const maxPerName = 3 counts := make(map[string]int) var result []skillResult for _, s := range skills { - key := strings.ToLower(s.SkillName) + key := strings.ToLower(s.qualifiedName()) if counts[key] >= maxPerName { continue } @@ -485,7 +499,7 @@ func renderTable(io *iostreams.IOStreams, skills []skillResult) error { table := tableprinter.New(io, tableprinter.WithHeader("REPOSITORY", "SKILL", "DESCRIPTION", "STARS")) for _, s := range skills { table.AddField(s.Repo) - table.AddField(s.SkillName) + table.AddField(s.qualifiedName()) desc := s.Description if isTTY { desc = text.Truncate(descWidth, desc) @@ -523,7 +537,7 @@ func promptInstall(opts *SearchOptions, skills []skillResult) error { desc := strings.Join(strings.Fields(s.Description), " ") descStr = "\n " + cs.Muted(text.Truncate(descWidth, desc)) } - options[i] = s.SkillName + " " + cs.Muted(s.Repo) + starStr + descStr + options[i] = s.qualifiedName() + " " + cs.Muted(s.Repo) + starStr + descStr } indices, err := opts.Prompter.MultiSelect( @@ -559,18 +573,27 @@ func promptInstall(opts *SearchOptions, skills []skillResult) error { for _, idx := range indices { s := skills[idx] + displayName := s.qualifiedName() fmt.Fprintf(opts.IO.ErrOut, "\n%s Installing %s from %s...\n", - cs.Blue("::"), s.SkillName, s.Repo) + cs.Blue("::"), displayName, s.Repo) + + // Use the repo-relative path (e.g. "skills/author/name") for + // disambiguation when installing namespaced skills, so the + // install command can resolve the exact skill without ambiguity. + installArg := s.SkillName + if s.Namespace != "" { + installArg = s.Path + } //nolint:gosec // arguments are from user-selected search results, not arbitrary input - cmd := exec.Command(opts.Executable, "skills", "install", s.Repo, s.SkillName, + cmd := exec.Command(opts.Executable, "skills", "install", s.Repo, installArg, "--agent", host.ID, "--scope", scope) cmd.Stdin = os.Stdin cmd.Stdout = opts.IO.Out cmd.Stderr = opts.IO.ErrOut if err := cmd.Run(); err != nil { fmt.Fprintf(opts.IO.ErrOut, "%s Failed to install %s from %s: %s\n", - cs.Red("!"), s.SkillName, s.Repo, err) + cs.Red("!"), displayName, s.Repo, err) } } @@ -581,6 +604,7 @@ func promptInstall(opts *SearchOptions, skills []skillResult) error { // Higher scores rank first. Signals (in priority order): // - Exact skill name match (3 000 points) // - Partial skill name match (1 000 points) +// - Namespace match (500 points) // - Description contains query (100 points) // - Repository stars (sqrt bonus, ~2 400 for 6k stars) func relevanceScore(s skillResult, query string) int { @@ -597,6 +621,11 @@ func relevanceScore(s skillResult, query string) int { score += 1_000 } + // Namespace match. + if s.Namespace != "" && strings.Contains(strings.ToLower(s.Namespace), term) { + score += 500 + } + // Description match. if strings.Contains(strings.ToLower(s.Description), term) { score += 100 @@ -613,7 +642,7 @@ func relevanceScore(s skillResult, query string) int { // filterByRelevance removes results that are not meaningfully related to // the query. A result is kept if the query term appears in the skill name, -// the YAML description, or the repository owner or name. +// the namespace, the YAML description, or the repository owner or name. func filterByRelevance(skills []skillResult, query string) []skillResult { queryTerm := strings.ToLower(query) termHyphen := strings.ReplaceAll(queryTerm, " ", "-") @@ -621,12 +650,14 @@ func filterByRelevance(skills []skillResult, query string) []skillResult { filtered := skills[:0] // reuse backing array for _, s := range skills { nameLower := strings.ToLower(s.SkillName) + namespaceLower := strings.ToLower(s.Namespace) descLower := strings.ToLower(s.Description) ownerLower := strings.ToLower(s.Owner) repoLower := strings.ToLower(s.RepoName) if strings.Contains(nameLower, queryTerm) || strings.Contains(nameLower, termHyphen) || + strings.Contains(namespaceLower, queryTerm) || strings.Contains(descLower, queryTerm) || strings.Contains(ownerLower, queryTerm) || strings.Contains(repoLower, queryTerm) { @@ -740,17 +771,17 @@ func fetchPrimaryPages(client *api.Client, host, query string, displayPage, disp return allItems, totalCount, nil } -// deduplicateResults extracts unique (repo, skill name) pairs from code search hits. +// deduplicateResults extracts unique (repo, namespace, skill name) triples from code search hits. func deduplicateResults(items []codeSearchItem) []skillResult { seen := make(map[string]struct{}) var results []skillResult for _, item := range items { - skillName := extractSkillName(item.Path) + skillName, namespace := extractSkillInfo(item.Path) if skillName == "" { continue } - key := item.Repository.FullName + "/" + skillName + key := item.Repository.FullName + "/" + namespace + "/" + skillName if _, ok := seen[key]; ok { continue } @@ -762,6 +793,7 @@ func deduplicateResults(items []codeSearchItem) []skillResult { Owner: owner, RepoName: repoName, SkillName: skillName, + Namespace: namespace, Path: item.Path, BlobSHA: item.SHA, }) @@ -818,11 +850,11 @@ func fetchDescriptions(client *api.Client, host string, skills []skillResult) ma return descs } -// extractSkillName derives the skill name from a SKILL.md path, but only if -// the path matches a known skill convention (skills/*, skills/scope/*, root-level, -// or plugins/*/skills/*). Returns empty string for non-conforming paths. -func extractSkillName(filePath string) string { - return discovery.MatchesSkillPath(filePath) +// extractSkillInfo derives the skill name and namespace from a SKILL.md path, +// but only if the path matches a known skill convention. Returns empty strings +// for non-conforming paths. +func extractSkillInfo(filePath string) (name, namespace string) { + return discovery.MatchSkillPath(filePath) } // formatStars formats a star count for display (e.g. 1700 > "1.7k"). diff --git a/pkg/cmd/skills/search/search_test.go b/pkg/cmd/skills/search/search_test.go index b3b177c64..bdfe3ba19 100644 --- a/pkg/cmd/skills/search/search_test.go +++ b/pkg/cmd/skills/search/search_test.go @@ -190,7 +190,7 @@ func TestSearchRun(t *testing.T) { httpStubs: func(reg *httpmock.Registry) { stubKeywordSearch(reg, `{"total_count": 1, "incomplete_results": false, "items": [{"name": "SKILL.md", "path": "skills/author/my-skill/SKILL.md", "repository": {"full_name": "org/repo"}}]}`) }, - wantStdout: "org/repo\tmy-skill\t\t0\n", + wantStdout: "org/repo\tauthor/my-skill\t\t0\n", }, { name: "ranks name-matching results first", @@ -225,6 +225,18 @@ func TestSearchRun(t *testing.T) { }, wantErr: `no skills found on page 999 for query "terraform"`, }, + { + name: "namespaced skills are kept distinct in same repo", + tty: false, + opts: &SearchOptions{Query: "commit", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + stubKeywordSearch(reg, `{"total_count": 2, "incomplete_results": false, "items": [ + {"name": "SKILL.md", "path": "skills/kynan/commit/SKILL.md", "repository": {"full_name": "org/skills-repo"}}, + {"name": "SKILL.md", "path": "skills/will/commit/SKILL.md", "repository": {"full_name": "org/skills-repo"}} + ]}`) + }, + wantStdout: "org/skills-repo\tkynan/commit\t\t0\norg/skills-repo\twill/commit\t\t0\n", + }, { name: "json output with selected fields", tty: false, @@ -398,28 +410,52 @@ func TestDeduplicateResults(t *testing.T) { assert.Equal(t, "terraform", results[2].SkillName) } -func TestExtractSkillName(t *testing.T) { +func TestDeduplicateResults_Namespaced(t *testing.T) { + items := []codeSearchItem{ + {Path: "skills/kynan/commit/SKILL.md", Repository: codeSearchRepository{FullName: "org/repo"}}, + {Path: "skills/will/commit/SKILL.md", Repository: codeSearchRepository{FullName: "org/repo"}}, + {Path: "skills/kynan/commit/SKILL.md", Repository: codeSearchRepository{FullName: "org/repo"}}, // duplicate + {Path: "skills/commit/SKILL.md", Repository: codeSearchRepository{FullName: "org/repo"}}, // non-namespaced + } + + results := deduplicateResults(items) + + require.Equal(t, 3, len(results)) + assert.Equal(t, "commit", results[0].SkillName) + assert.Equal(t, "kynan", results[0].Namespace) + assert.Equal(t, "commit", results[1].SkillName) + assert.Equal(t, "will", results[1].Namespace) + assert.Equal(t, "commit", results[2].SkillName) + assert.Equal(t, "", results[2].Namespace) +} + +func TestExtractSkillInfo(t *testing.T) { tests := []struct { - path string - want string + path string + wantName string + wantNamespace string }{ - {"skills/terraform/SKILL.md", "terraform"}, - {"skills/author/my-skill/SKILL.md", "my-skill"}, - {"SKILL.md", ""}, - {"skills/docker/SKILL.md", "docker"}, + {"skills/terraform/SKILL.md", "terraform", ""}, + {"skills/author/my-skill/SKILL.md", "my-skill", "author"}, + {"SKILL.md", "", ""}, + {"skills/docker/SKILL.md", "docker", ""}, // Root-level convention - {"my-skill/SKILL.md", "my-skill"}, + {"my-skill/SKILL.md", "my-skill", ""}, // Plugins convention - {"plugins/openai/skills/chat/SKILL.md", "chat"}, + {"plugins/openai/skills/chat/SKILL.md", "chat", "openai"}, // Non-matching paths should be filtered out - {"random/nested/deep/SKILL.md", ""}, - {".hidden/SKILL.md", ""}, + {"random/nested/deep/SKILL.md", "", ""}, + {".hidden/SKILL.md", "", ""}, + // Same-name skills with different namespaces + {"skills/kynan/commit/SKILL.md", "commit", "kynan"}, + {"skills/will/commit/SKILL.md", "commit", "will"}, } for _, tt := range tests { t.Run(tt.path, func(t *testing.T) { - got := extractSkillName(tt.path) - assert.Equal(t, tt.want, got) + gotName, gotNamespace := extractSkillInfo(tt.path) + assert.Equal(t, tt.wantName, gotName) + assert.Equal(t, tt.wantNamespace, gotNamespace) }) } } @@ -432,18 +468,22 @@ func TestFilterByRelevance(t *testing.T) { {Repo: "acme/terraform-tools", Owner: "acme", RepoName: "terraform-tools", SkillName: "validator"}, {Repo: "x/y", Owner: "x", RepoName: "y", SkillName: "unrelated", Description: "terraform integration"}, {Repo: "x/z", Owner: "x", RepoName: "z", SkillName: "noise"}, + {Repo: "org/repo3", Owner: "org", RepoName: "repo3", SkillName: "deploy", Namespace: "terraform"}, } filtered := filterByRelevance(skills, "terraform") // Should keep: name match (terraform), owner match (terraform-corp), - // repo name match (terraform-tools), description match (terraform integration). + // repo name match (terraform-tools), description match (terraform integration), + // namespace match (terraform/deploy). // Should drop: docker, noise. - assert.Equal(t, 4, len(filtered)) + assert.Equal(t, 5, len(filtered)) assert.Equal(t, "terraform", filtered[0].SkillName) assert.Equal(t, "linter", filtered[1].SkillName) assert.Equal(t, "validator", filtered[2].SkillName) assert.Equal(t, "unrelated", filtered[3].SkillName) + assert.Equal(t, "deploy", filtered[4].SkillName) + assert.Equal(t, "terraform", filtered[4].Namespace) } func TestRankByRelevance(t *testing.T) { @@ -485,3 +525,54 @@ func TestFormatStars(t *testing.T) { assert.Equal(t, "1.7k", formatStars(1700)) assert.Equal(t, "12.5k", formatStars(12500)) } + +func TestQualifiedName(t *testing.T) { + tests := []struct { + name string + skill skillResult + want string + }{ + { + name: "no namespace", + skill: skillResult{SkillName: "terraform"}, + want: "terraform", + }, + { + name: "with namespace", + skill: skillResult{SkillName: "commit", Namespace: "kynan"}, + want: "kynan/commit", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.skill.qualifiedName()) + }) + } +} + +func TestDeduplicateByName_Namespaced(t *testing.T) { + // Skills with the same base name but different namespaces should + // be treated as distinct and not collapsed against each other. + skills := []skillResult{ + {Repo: "org/repo1", SkillName: "commit", Namespace: "kynan"}, + {Repo: "org/repo2", SkillName: "commit", Namespace: "will"}, + {Repo: "org/repo3", SkillName: "commit"}, + {Repo: "org/repo4", SkillName: "commit", Namespace: "kynan"}, + {Repo: "org/repo5", SkillName: "commit", Namespace: "kynan"}, + {Repo: "org/repo6", SkillName: "commit", Namespace: "kynan"}, // should be capped (4th kynan/commit) + } + + result := deduplicateByName(skills) + + // kynan/commit capped at 3, will/commit has 1, bare commit has 1 = 5 total + require.Equal(t, 5, len(result)) + assert.Equal(t, "kynan", result[0].Namespace) + assert.Equal(t, "will", result[1].Namespace) + assert.Equal(t, "", result[2].Namespace) + assert.Equal(t, "kynan", result[3].Namespace) + assert.Equal(t, "kynan", result[4].Namespace) + // repo6 should have been dropped + for _, s := range result { + assert.NotEqual(t, "org/repo6", s.Repo) + } +} From 013d531101d172afeffc6d907ccb60f26bb4dc70 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 15 Apr 2026 23:36:25 +0200 Subject: [PATCH 16/27] chore: remove unused newTestGitClient function Remove the now-unused newTestGitClient helper that was left behind after refactoring tests to use initGitRepo directly. This fixes the golangci-lint 'unused' error in CI. Co-authored-by: BagToad <47394200+BagToad@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/skills/publish/publish_test.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/pkg/cmd/skills/publish/publish_test.go b/pkg/cmd/skills/publish/publish_test.go index 4bfd05981..6da0e01a4 100644 --- a/pkg/cmd/skills/publish/publish_test.go +++ b/pkg/cmd/skills/publish/publish_test.go @@ -20,13 +20,6 @@ import ( "github.com/stretchr/testify/require" ) -func newTestGitClient(t *testing.T, remoteURLs map[string]string) *git.Client { - t.Helper() - dir := t.TempDir() - initGitRepo(t, dir, remoteURLs) - return &git.Client{RepoDir: dir} -} - // initGitRepo initializes a git repo in the given directory and adds remotes. // Use this when the git repo must live in the same directory as the skill files. func initGitRepo(t *testing.T, dir string, remoteURLs map[string]string) { From e04dceb3b5bcf8f18117e87314e7e2cd66427a70 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 15 Apr 2026 23:39:11 +0200 Subject: [PATCH 17/27] fix: address review feedback on namespace changes - Keep skillName as bare name in JSON output for backward compat; namespace is available as a separate --json field - Fix Namespace field comment to cover plugin namespaces too - Trim /SKILL.md from install path arg to match comment - Rename acceptance test to skills-install-namespaced since it tests install disambiguation, not search Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...amespaced.txtar => skills-install-namespaced.txtar} | 2 +- pkg/cmd/skills/search/search.go | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) rename acceptance/testdata/skills/{skills-search-namespaced.txtar => skills-install-namespaced.txtar} (96%) diff --git a/acceptance/testdata/skills/skills-search-namespaced.txtar b/acceptance/testdata/skills/skills-install-namespaced.txtar similarity index 96% rename from acceptance/testdata/skills/skills-search-namespaced.txtar rename to acceptance/testdata/skills/skills-install-namespaced.txtar index e0fb888cb..db39bead0 100644 --- a/acceptance/testdata/skills/skills-search-namespaced.txtar +++ b/acceptance/testdata/skills/skills-install-namespaced.txtar @@ -1,5 +1,5 @@ # Two namespaced skills with the same base name in the same repo should -# both appear in search results and be independently installable. +# be independently installable using path-based disambiguation. # Use gh as a credential helper exec gh auth setup-git diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go index 836fa3de5..bc1c819e9 100644 --- a/pkg/cmd/skills/search/search.go +++ b/pkg/cmd/skills/search/search.go @@ -163,7 +163,7 @@ type skillResult struct { Owner string // parsed from Repo RepoName string // parsed from Repo SkillName string - Namespace string // author/scope prefix for namespaced skills + Namespace string // namespace prefix: author/scope for skills/{author}/* or plugin name for plugins/{plugin}/skills/* Description string Path string // original file path (e.g. skills/terraform/SKILL.md) BlobSHA string @@ -187,7 +187,7 @@ func (s skillResult) ExportData(fields []string) map[string]interface{} { case "repo": data[f] = s.Repo case "skillName": - data[f] = s.qualifiedName() + data[f] = s.SkillName case "namespace": data[f] = s.Namespace case "description": @@ -577,12 +577,12 @@ func promptInstall(opts *SearchOptions, skills []skillResult) error { fmt.Fprintf(opts.IO.ErrOut, "\n%s Installing %s from %s...\n", cs.Blue("::"), displayName, s.Repo) - // Use the repo-relative path (e.g. "skills/author/name") for - // disambiguation when installing namespaced skills, so the + // Use the repo-relative directory path (e.g. "skills/author/name") + // for disambiguation when installing namespaced skills, so the // install command can resolve the exact skill without ambiguity. installArg := s.SkillName if s.Namespace != "" { - installArg = s.Path + installArg = strings.TrimSuffix(s.Path, "/SKILL.md") } //nolint:gosec // arguments are from user-selected search results, not arbitrary input From f2d978d960ee5133ef1e0f5a19c8a3eb259bf556 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 15 Apr 2026 22:58:03 +0200 Subject: [PATCH 18/27] Disable auth check for local-only skill flags Add cmdutil.DisableAuthCheckFlag for --from-local on install so that installing from a local directory does not require authentication. This follows the same pattern used by attestation verify for its --bundle flag. The --dry-run flag on publish is intentionally left with auth enabled because dry-run validation includes remote repository checks (security settings, tag protection, topics). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/skills/install/install.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index fce53583d..27325b633 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -199,6 +199,7 @@ func NewCmdInstall(f *cmdutil.Factory, runF func(*InstallOptions) error) *cobra. cmd.Flags().StringVar(&opts.Dir, "dir", "", "Install to a custom directory (overrides --agent and --scope)") cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Overwrite existing skills without prompting") cmd.Flags().BoolVar(&opts.FromLocal, "from-local", false, "Treat the argument as a local directory path instead of a repository") + cmdutil.DisableAuthCheckFlag(cmd.Flags().Lookup("from-local")) return cmd } From 9f9b93aa6aa1c5d3aedb2d22533ee61d68bf7c83 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 15 Apr 2026 23:34:54 +0200 Subject: [PATCH 19/27] URL-encode parentPath in skills discovery API call The parentPath parameter in the contents API path was not URL-encoded, which would cause failures when paths contain spaces or other special characters. Apply url.PathEscape() to parentPath, consistent with the rest of the file. commitSHA is left unescaped since SHAs are hex-only and never need encoding. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/skills/discovery/discovery.go | 2 +- internal/skills/discovery/discovery_test.go | 27 ++++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/internal/skills/discovery/discovery.go b/internal/skills/discovery/discovery.go index 84f2aa596..4a6bdb940 100644 --- a/internal/skills/discovery/discovery.go +++ b/internal/skills/discovery/discovery.go @@ -491,7 +491,7 @@ func DiscoverSkillByPath(client *api.Client, host, owner, repo, commitSHA, skill } parentPath := path.Dir(skillPath) - apiPath := fmt.Sprintf("repos/%s/%s/contents/%s?ref=%s", url.PathEscape(owner), url.PathEscape(repo), parentPath, commitSHA) + 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"` diff --git a/internal/skills/discovery/discovery_test.go b/internal/skills/discovery/discovery_test.go index 2de7ef683..e2333dfd1 100644 --- a/internal/skills/discovery/discovery_test.go +++ b/internal/skills/discovery/discovery_test.go @@ -699,7 +699,7 @@ func TestDiscoverSkillByPath(t *testing.T) { skillPath: "skills/monalisa/issue-triage", stubs: func(reg *httpmock.Registry) { reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/skills/monalisa"), + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/skills%2Fmonalisa"), httpmock.JSONResponse([]map[string]interface{}{ {"name": "issue-triage", "path": "skills/monalisa/issue-triage", "sha": "tree-sha", "type": "dir"}, })) @@ -720,6 +720,31 @@ func TestDiscoverSkillByPath(t *testing.T) { wantName: "issue-triage", wantNS: "monalisa", }, + { + name: "parent path with spaces is URL encoded", + skillPath: "my skills/code-review", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/my%20skills"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "code-review", "path": "my skills/code-review", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "blob-sha"}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "blob-sha", "encoding": "base64", "content": "IyBTa2lsbA==", + })) + }, + wantName: "code-review", + }, { name: "strips trailing SKILL.md from path", skillPath: "skills/code-review/SKILL.md", From 9552d225ccaebf9c5b36af6023320800d0bf2e68 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Thu, 16 Apr 2026 00:35:39 +0200 Subject: [PATCH 20/27] Update acceptance/testdata/skills/skills-search-noresults.txtar Co-authored-by: Kynan Ware <47394200+BagToad@users.noreply.github.com> --- acceptance/testdata/skills/skills-search-noresults.txtar | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acceptance/testdata/skills/skills-search-noresults.txtar b/acceptance/testdata/skills/skills-search-noresults.txtar index 425666556..c51d7b568 100644 --- a/acceptance/testdata/skills/skills-search-noresults.txtar +++ b/acceptance/testdata/skills/skills-search-noresults.txtar @@ -1,4 +1,4 @@ # Search for something unlikely to exist returns empty stdout -# (NoResultsError is silent in non-TTY — exits 0 with no output) +# NoResultsError is silent in non-TTY (exits 0 with no output) exec gh skill search zzzznonexistenttotallyfakeskillxyz123 ! stdout . From a6e9722ec16dcfa89b6ca3dadeeab7a9ebf17092 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Thu, 16 Apr 2026 10:20:24 +0100 Subject: [PATCH 21/27] fix skills names in examples --- pkg/cmd/skills/preview/preview.go | 6 +++--- pkg/cmd/skills/skills.go | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index 319288f17..8ffc75dfc 100644 --- a/pkg/cmd/skills/preview/preview.go +++ b/pkg/cmd/skills/preview/preview.go @@ -69,13 +69,13 @@ func NewCmdPreview(f *cmdutil.Factory, runF func(*PreviewOptions) error) *cobra. `), Example: heredoc.Doc(` # Preview a specific skill - $ gh skill preview github/awesome-copilot code-review + $ gh skill preview github/awesome-copilot documentation-writer # Preview a skill at a specific version - $ gh skill preview github/awesome-copilot code-review@v1.2.0 + $ gh skill preview github/awesome-copilot documentation-writer@v1.2.0 # Preview a skill at a specific commit SHA - $ gh skill preview github/awesome-copilot code-review@abc123def456 + $ gh skill preview github/awesome-copilot documentation-writer@abc123def456 # Browse and preview interactively $ gh skill preview github/awesome-copilot diff --git a/pkg/cmd/skills/skills.go b/pkg/cmd/skills/skills.go index 2989c52f8..1dadd3b1f 100644 --- a/pkg/cmd/skills/skills.go +++ b/pkg/cmd/skills/skills.go @@ -29,10 +29,10 @@ func NewCmdSkills(f *cmdutil.Factory) *cobra.Command { $ gh skill search terraform # Install a skill - $ gh skill install github/awesome-copilot code-review + $ gh skill install github/awesome-copilot documentation-writer # Preview a skill before installing - $ gh skill preview github/awesome-copilot code-review + $ gh skill preview github/awesome-copilot documentation-writer # Update all installed skills $ gh skill update --all From 3dce81a0d622e63a0c7703e1c32d964a2f6ad12b Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 16 Apr 2026 14:03:54 +0100 Subject: [PATCH 22/27] docs(skill): improve help docs Signed-off-by: Babak K. Shandiz --- pkg/cmd/skills/install/install.go | 12 ++++++------ pkg/cmd/skills/preview/preview.go | 13 ++++++------- pkg/cmd/skills/publish/publish.go | 16 ++++++++-------- pkg/cmd/skills/search/search.go | 11 +++++------ pkg/cmd/skills/update/update.go | 16 ++++++++-------- 5 files changed, 33 insertions(+), 35 deletions(-) diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index 27325b633..1b6a7fd8f 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -78,12 +78,12 @@ func NewCmdInstall(f *cmdutil.Factory, runF func(*InstallOptions) error) *cobra. scope (in your home directory, available everywhere). Supported hosts and their storage directories are (project, user): - - GitHub Copilot (%[1]s.agents/skills%[1]s, %[1]s~/.copilot/skills%[1]s) - - Claude Code (%[1]s.claude/skills%[1]s, %[1]s~/.claude/skills%[1]s) - - Cursor (%[1]s.agents/skills%[1]s, %[1]s~/.cursor/skills%[1]s) - - Codex (%[1]s.agents/skills%[1]s, %[1]s~/.codex/skills%[1]s) - - Gemini CLI (%[1]s.agents/skills%[1]s, %[1]s~/.gemini/skills%[1]s) - - Antigravity (%[1]s.agents/skills%[1]s, %[1]s~/.gemini/antigravity/skills%[1]s) + - GitHub Copilot (%[1]s.agents/skills%[1]s, %[1]s~/.copilot/skills%[1]s) + - Claude Code (%[1]s.claude/skills%[1]s, %[1]s~/.claude/skills%[1]s) + - Cursor (%[1]s.agents/skills%[1]s, %[1]s~/.cursor/skills%[1]s) + - Codex (%[1]s.agents/skills%[1]s, %[1]s~/.codex/skills%[1]s) + - Gemini CLI (%[1]s.agents/skills%[1]s, %[1]s~/.gemini/skills%[1]s) + - Antigravity (%[1]s.agents/skills%[1]s, %[1]s~/.gemini/antigravity/skills%[1]s) Use %[1]s--agent%[1]s and %[1]s--scope%[1]s to control placement, or %[1]s--dir%[1]s for a custom directory. The default scope is %[1]sproject%[1]s, and the default diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index 8ffc75dfc..e39886ecd 100644 --- a/pkg/cmd/skills/preview/preview.go +++ b/pkg/cmd/skills/preview/preview.go @@ -50,12 +50,12 @@ func NewCmdPreview(f *cmdutil.Factory, runF func(*PreviewOptions) error) *cobra. cmd := &cobra.Command{ Use: "preview []", Short: "Preview a skill from a GitHub repository (preview)", - Long: heredoc.Doc(` - Render a skill's SKILL.md content in the terminal. This fetches the + Long: heredoc.Docf(` + Render a skill's %[1]sSKILL.md%[1]s content in the terminal. This fetches the skill file from the repository and displays it using the configured pager, without installing anything. - A file tree is shown first, followed by the rendered SKILL.md content. + A file tree is shown first, followed by the rendered %[1]sSKILL.md%[1]s content. When running interactively and the skill contains additional files (scripts, references, etc.), a file picker lets you browse them individually. @@ -63,10 +63,9 @@ func NewCmdPreview(f *cmdutil.Factory, runF func(*PreviewOptions) error) *cobra. When run with only a repository argument, lists available skills and prompts for selection. - To preview a specific version of the skill, append @VERSION to the - skill name. The version is resolved as a git tag, branch, or commit - SHA. - `), + To preview a specific version of the skill, append %[1]s@VERSION%[1]s to the + skill name. The version is resolved as a git tag, branch, or commit SHA. + `, "`"), Example: heredoc.Doc(` # Preview a specific skill $ gh skill preview github/awesome-copilot documentation-writer diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index e178ae535..b86a02788 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -98,29 +98,29 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*PublishOptions) error) *cobra. cmd := &cobra.Command{ Use: "publish [] [flags]", Short: "Validate and publish skills to a GitHub repository (preview)", - Long: heredoc.Doc(` + Long: heredoc.Docf(` Validate a local repository's skills against the Agent Skills specification and publish them by creating a GitHub release. Validation checks include: - - Skills follow the skills/*/SKILL.md directory convention + - Skills follow the %[1]sskills/*/SKILL.md%[1]s directory convention - Skill names match the strict agentskills.io naming rules - Each skill name matches its directory name - Required frontmatter fields (name, description) are present - allowed-tools is a string, not an array - - Install metadata (metadata.github-*) is stripped if present + - Install metadata (%[1]smetadata.github-*%[1]s) is stripped if present After validation passes, publish will interactively guide you through: - - Adding the "agent-skills" topic to the repository + - Adding the %[1]sagent-skills%[1]s topic to the repository - Choosing a version tag (semver recommended) - Creating a GitHub release with auto-generated notes - Use --dry-run to validate without publishing. - Use --tag to publish non-interactively with a specific tag. - Use --fix to automatically strip install metadata from committed files. - `), + Use %[1]s--dry-run%[1]s to validate without publishing. + Use %[1]s--tag%[1]s to publish non-interactively with a specific tag. + Use %[1]s--fix%[1]s to automatically strip install metadata from committed files. + `, "`"), Example: heredoc.Doc(` # Validate and publish interactively $ gh skill publish diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go index 3d3114f3d..d94d05b47 100644 --- a/pkg/cmd/skills/search/search.go +++ b/pkg/cmd/skills/search/search.go @@ -73,20 +73,19 @@ func NewCmdSearch(f *cmdutil.Factory, runF func(*SearchOptions) error) *cobra.Co cmd := &cobra.Command{ Use: "search [flags]", Short: "Search for skills across GitHub (preview)", - Long: heredoc.Doc(` + Long: heredoc.Docf(` Search across all public GitHub repositories for skills matching a keyword. - Uses the GitHub Code Search API to find SKILL.md files whose name or + Uses the GitHub Code Search API to find %[1]sSKILL.md%[1]s files whose name or description matches the query term. Results are ranked by relevance: skills whose name contains the query term appear first. - Use --owner to scope results to a specific GitHub user or organization. + Use %[1]s--owner%[1]s to scope results to a specific GitHub user or organization. - In interactive mode, you can select skills from the results to install - directly. - `), + In interactive mode, you can select skills from the results to install directly. + `, "`"), Example: heredoc.Doc(` # Search for skills related to terraform $ gh skill search terraform diff --git a/pkg/cmd/skills/update/update.go b/pkg/cmd/skills/update/update.go index ad7c647d5..7923a6bde 100644 --- a/pkg/cmd/skills/update/update.go +++ b/pkg/cmd/skills/update/update.go @@ -75,9 +75,9 @@ func NewCmdUpdate(f *cmdutil.Factory, runF func(*UpdateOptions) error) *cobra.Co cmd := &cobra.Command{ Use: "update [...] [flags]", Short: "Update installed skills to their latest versions (preview)", - Long: heredoc.Doc(` + Long: heredoc.Docf(` Checks installed skills for available updates by comparing the local - tree SHA (from SKILL.md frontmatter) against the remote repository. + tree SHA (from %[1]sSKILL.md%[1]s frontmatter) against the remote repository. Scans all known agent host directories (Copilot, Claude, Cursor, Codex, Gemini, Antigravity) in both project and user scope automatically. @@ -85,8 +85,8 @@ func NewCmdUpdate(f *cmdutil.Factory, runF func(*UpdateOptions) error) *cobra.Co Without arguments, checks all installed skills. With skill names, checks only those specific skills. - Pinned skills (installed with --pin) are skipped with a notice. - Use --unpin to clear the pinned version and include those skills + Pinned skills (installed with %[1]s--pin%[1]s) are skipped with a notice. + Use %[1]s--unpin%[1]s to clear the pinned version and include those skills in the update. Skills without GitHub metadata (e.g. installed manually or by another @@ -94,14 +94,14 @@ func NewCmdUpdate(f *cmdutil.Factory, runF func(*UpdateOptions) error) *cobra.Co The update re-downloads the skill with metadata injected, so future updates work automatically. - With --force, re-downloads skills even when the remote version matches + With %[1]s--force%[1]s, re-downloads skills even when the remote version matches the local tree SHA. This overwrites locally modified skill files with their original content, but does not remove extra files added locally. In interactive mode, shows which skills have updates and asks for - confirmation before proceeding. With --all, updates without prompting. - With --dry-run, reports available updates without modifying any files. - `), + confirmation before proceeding. With %[1]s--all%[1]s, updates without prompting. + With %[1]s--dry-run%[1]s, reports available updates without modifying any files. + `, "`"), Example: heredoc.Doc(` # Check and update all skills interactively $ gh skill update From a1eb707e26ea99c0c640c72011148be19a3e30d6 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 16 Apr 2026 14:04:29 +0100 Subject: [PATCH 23/27] fix(skill publish): remove misleading `validate` alias Signed-off-by: Babak K. Shandiz --- pkg/cmd/skills/publish/publish.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index b86a02788..931b9085c 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -134,8 +134,7 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*PublishOptions) error) *cobra. # Validate and strip install metadata $ gh skills publish --fix `), - Aliases: []string{"validate"}, - Args: cobra.MaximumNArgs(1), + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 1 { opts.Dir = args[0] From b63f5bfd9ac136546b7d7cda88bb951dbe010a42 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 15 Apr 2026 22:36:33 +0200 Subject: [PATCH 24/27] refactor: use shared discovery logic in publish command Replace the hardcoded skills/ directory requirement in the publish command with the shared DiscoverLocalSkills() function used by install and other commands. This removes the opinionated restriction that skills must live under a skills/ directory and supports all discovery conventions: - skills/*/SKILL.md - skills/{scope}/*/SKILL.md - */SKILL.md (root-level) - plugins/{scope}/skills/*/SKILL.md All per-skill validation (frontmatter, spec-compliant naming, metadata stripping, etc.) is preserved. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/skills/publish/publish.go | 124 ++++++++++++------------- pkg/cmd/skills/publish/publish_test.go | 83 ++++++++++++++++- 2 files changed, 140 insertions(+), 67 deletions(-) diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index 931b9085c..5737c2a31 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "os" + "path" "path/filepath" "regexp" "sort" @@ -102,9 +103,15 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*PublishOptions) error) *cobra. Validate a local repository's skills against the Agent Skills specification and publish them by creating a GitHub release. + Skills are discovered using the same conventions as install: + + - %[1]sskills/*/SKILL.md%[1]s + - %[1]sskills/{scope}/*/SKILL.md%[1]s + - %[1]s*/SKILL.md%[1]s (root-level) + - %[1]splugins/{scope}/skills/*/SKILL.md%[1]s + Validation checks include: - - Skills follow the %[1]sskills/*/SKILL.md%[1]s directory convention - Skill names match the strict agentskills.io naming rules - Each skill name matches its directory name - Required frontmatter fields (name, description) are present @@ -176,36 +183,18 @@ func publishRun(opts *PublishOptions) error { var diagnostics []publishDiagnostic - // Check for skills directory - skillsDir := filepath.Join(dir, "skills") - info, err := os.Stat(skillsDir) - if err != nil || !info.IsDir() { - return fmt.Errorf("no skills/ directory found in %s; run this command from a repository root containing a skills/ directory", dir) - } - - // Discover skill directories - entries, err := os.ReadDir(skillsDir) + skills, err := discovery.DiscoverLocalSkills(dir) if err != nil { - return fmt.Errorf("could not read skills/ directory: %w", err) + return err } - var skillDirs []string - for _, e := range entries { - if e.IsDir() { - skillDirs = append(skillDirs, e.Name()) - } - } - - if len(skillDirs) == 0 { - return fmt.Errorf("no skill directories found in %s/skills/", dir) - } - - for _, dirName := range skillDirs { - skillPath := filepath.Join(skillsDir, dirName, "SKILL.md") + for _, skill := range skills { + dirName := path.Base(skill.Path) + skillPath := filepath.Join(dir, filepath.FromSlash(skill.Path), "SKILL.md") content, err := os.ReadFile(skillPath) if err != nil { diagnostics = append(diagnostics, publishDiagnostic{ - skill: dirName, + skill: skill.DisplayName(), severity: "error", message: "missing SKILL.md file", }) @@ -215,7 +204,7 @@ func publishRun(opts *PublishOptions) error { result, err := frontmatter.Parse(string(content)) if err != nil { diagnostics = append(diagnostics, publishDiagnostic{ - skill: dirName, + skill: skill.DisplayName(), severity: "error", message: fmt.Sprintf("invalid frontmatter YAML: %s", err), }) @@ -225,7 +214,7 @@ func publishRun(opts *PublishOptions) error { // Validate name field exists if result.Metadata.Name == "" { diagnostics = append(diagnostics, publishDiagnostic{ - skill: dirName, + skill: skill.DisplayName(), severity: "error", message: "missing required field: name", }) @@ -233,7 +222,7 @@ func publishRun(opts *PublishOptions) error { // Validate name matches directory if result.Metadata.Name != dirName { diagnostics = append(diagnostics, publishDiagnostic{ - skill: dirName, + skill: skill.DisplayName(), severity: "error", message: fmt.Sprintf("name %q does not match directory name %q", result.Metadata.Name, dirName), }) @@ -242,7 +231,7 @@ func publishRun(opts *PublishOptions) error { // Validate name is spec-compliant if !discovery.IsSpecCompliant(result.Metadata.Name) { diagnostics = append(diagnostics, publishDiagnostic{ - skill: dirName, + skill: skill.DisplayName(), severity: "error", message: fmt.Sprintf("name %q does not follow agentskills.io naming convention (lowercase alphanumeric + hyphens)", result.Metadata.Name), }) @@ -252,13 +241,13 @@ func publishRun(opts *PublishOptions) error { // Validate description field exists if result.Metadata.Description == "" { diagnostics = append(diagnostics, publishDiagnostic{ - skill: dirName, + skill: skill.DisplayName(), severity: "error", message: "missing required field: description", }) } else if len(result.Metadata.Description) > 1024 { diagnostics = append(diagnostics, publishDiagnostic{ - skill: dirName, + skill: skill.DisplayName(), severity: "warning", message: fmt.Sprintf("description is %d chars (recommended max: 1024)", len(result.Metadata.Description)), }) @@ -268,7 +257,7 @@ func publishRun(opts *PublishOptions) error { if raw, ok := result.RawYAML["allowed-tools"]; ok { if _, isSlice := raw.([]interface{}); isSlice { diagnostics = append(diagnostics, publishDiagnostic{ - skill: dirName, + skill: skill.DisplayName(), severity: "error", message: "allowed-tools must be a string (space-delimited), not an array", }) @@ -283,26 +272,26 @@ func publishRun(opts *PublishOptions) error { fixed, fixErr := stripGitHubMetadata(string(content)) if fixErr != nil { diagnostics = append(diagnostics, publishDiagnostic{ - skill: dirName, + skill: skill.DisplayName(), severity: "error", message: fmt.Sprintf("could not strip install metadata: %s", fixErr), }) } else if writeErr := os.WriteFile(skillPath, []byte(fixed), 0o644); writeErr != nil { diagnostics = append(diagnostics, publishDiagnostic{ - skill: dirName, + skill: skill.DisplayName(), severity: "error", message: fmt.Sprintf("could not write fixed SKILL.md: %s", writeErr), }) } else { diagnostics = append(diagnostics, publishDiagnostic{ - skill: dirName, + skill: skill.DisplayName(), severity: "fixed", message: fmt.Sprintf("stripped install metadata: %s", strings.Join(githubKeys, ", ")), }) } } else { diagnostics = append(diagnostics, publishDiagnostic{ - skill: dirName, + skill: skill.DisplayName(), severity: "error", message: fmt.Sprintf("contains install metadata that must be stripped: %s (use --fix)", strings.Join(githubKeys, ", ")), }) @@ -314,7 +303,7 @@ func publishRun(opts *PublishOptions) error { if result.Metadata.License == "" { if _, ok := result.RawYAML["license"]; !ok { diagnostics = append(diagnostics, publishDiagnostic{ - skill: dirName, + skill: skill.DisplayName(), severity: "warning", message: "recommended field missing: license", }) @@ -325,7 +314,7 @@ func publishRun(opts *PublishOptions) error { bodyLines := strings.Count(result.Body, "\n") + 1 if bodyLines > 500 { diagnostics = append(diagnostics, publishDiagnostic{ - skill: dirName, + skill: skill.DisplayName(), severity: "warning", message: fmt.Sprintf("skill body is %d lines (recommended max: 500 for efficient context)", bodyLines), }) @@ -376,7 +365,11 @@ func publishRun(opts *PublishOptions) error { if client != nil { // Security and ruleset checks (advisory, always shown) - securityDiags := checkSecuritySettings(client, host, owner, repo, skillsDir) + var skillAbsDirs []string + for _, skill := range skills { + skillAbsDirs = append(skillAbsDirs, filepath.Join(dir, filepath.FromSlash(skill.Path))) + } + securityDiags := checkSecuritySettings(client, host, owner, repo, skillAbsDirs) diagnostics = append(diagnostics, securityDiags...) rulesetDiags := checkTagProtection(client, host, owner, repo) @@ -406,7 +399,7 @@ func publishRun(opts *PublishOptions) error { } if canPrompt { - renderDiagnosticsTTY(opts, skillDirs, diagnostics, errors, warnings, fixes, owner, repo) + renderDiagnosticsTTY(opts, len(skills), diagnostics, errors, warnings, fixes, owner, repo) } else { renderDiagnosticsPlain(opts, diagnostics, errors, warnings) } @@ -707,7 +700,7 @@ func checkTagProtection(client *api.Client, host, owner, repo string) []publishD } // checkSecuritySettings checks whether recommended security features are enabled. -func checkSecuritySettings(client *api.Client, host, owner, repo, skillsDir string) []publishDiagnostic { +func checkSecuritySettings(client *api.Client, host, owner, repo string, skillDirs []string) []publishDiagnostic { if client == nil { return nil } @@ -738,7 +731,7 @@ func checkSecuritySettings(client *api.Client, host, owner, repo, skillsDir stri }) } - hasCode, hasManifests := detectCodeAndManifests(skillsDir) + hasCode, hasManifests := detectCodeAndManifests(skillDirs) if hasCode { alertsPath := fmt.Sprintf("repos/%s/%s/code-scanning/alerts?per_page=1&state=open", owner, repo) @@ -781,28 +774,35 @@ var manifestFiles = map[string]bool{ "composer.json": true, "composer.lock": true, } -// detectCodeAndManifests walks the skills directory looking for code files +// detectCodeAndManifests walks the skill directories looking for code files // and dependency manifests. -func detectCodeAndManifests(skillsDir string) (hasCode, hasManifests bool) { - _ = filepath.Walk(skillsDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if info.IsDir() { +func detectCodeAndManifests(skillDirs []string) (hasCode, hasManifests bool) { + for _, dir := range skillDirs { + _ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + ext := filepath.Ext(info.Name()) + if codeExtensions[ext] { + hasCode = true + } + if manifestFiles[info.Name()] { + hasManifests = true + } + if hasCode && hasManifests { + // Stop walking this skill directory early; the outer loop + // continues to process remaining skill directories. + return filepath.SkipAll + } return nil - } - ext := filepath.Ext(info.Name()) - if codeExtensions[ext] { - hasCode = true - } - if manifestFiles[info.Name()] { - hasManifests = true - } + }) if hasCode && hasManifests { - return filepath.SkipAll + return } - return nil - }) + } return } @@ -961,7 +961,7 @@ func detectMissingRepoDiagnostic(gitClient *git.Client, dir string) []publishDia }} } -func renderDiagnosticsTTY(opts *PublishOptions, skillDirs []string, diagnostics []publishDiagnostic, errors, warnings, fixes int, owner, repo string) { +func renderDiagnosticsTTY(opts *PublishOptions, skillCount int, diagnostics []publishDiagnostic, errors, warnings, fixes int, owner, repo string) { cs := opts.IO.ColorScheme() // Separate info messages from errors/warnings for cleaner output @@ -975,7 +975,7 @@ func renderDiagnosticsTTY(opts *PublishOptions, skillDirs []string, diagnostics } if len(issues) == 0 && fixes == 0 { - fmt.Fprintf(opts.IO.Out, "%s %d skill(s) validated successfully\n", cs.SuccessIcon(), len(skillDirs)) + fmt.Fprintf(opts.IO.Out, "%s %d skill(s) validated successfully\n", cs.SuccessIcon(), skillCount) } else { for _, d := range issues { var prefix string diff --git a/pkg/cmd/skills/publish/publish_test.go b/pkg/cmd/skills/publish/publish_test.go index 6da0e01a4..d34153441 100644 --- a/pkg/cmd/skills/publish/publish_test.go +++ b/pkg/cmd/skills/publish/publish_test.go @@ -168,16 +168,16 @@ func TestPublishRun(t *testing.T) { wantStderr string }{ { - name: "no skills directory", + name: "no skills found", setup: func(_ *testing.T, _ string) {}, opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { t.Helper() return &PublishOptions{IO: ios, Dir: dir} }, - wantErr: "no skills/ directory", + wantErr: "no skills found", }, { - name: "missing SKILL.md", + name: "empty skills directory has no discoverable skills", setup: func(t *testing.T, dir string) { t.Helper() require.NoError(t, os.MkdirAll(filepath.Join(dir, "skills", "empty-skill"), 0o755)) @@ -186,8 +186,7 @@ func TestPublishRun(t *testing.T) { t.Helper() return &PublishOptions{IO: ios, Dir: dir} }, - wantErr: "validation failed", - wantStdout: "missing SKILL.md", + wantErr: "no skills found", }, { name: "missing name in frontmatter", @@ -245,6 +244,80 @@ func TestPublishRun(t *testing.T) { wantErr: "validation failed", wantStdout: "naming convention", }, + { + name: "root-level skill discovered and validated", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + // Create a root-level skill (*/SKILL.md convention) + skillDir := filepath.Join(dir, "my-root-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: my-root-skill + description: A root-level skill + license: MIT + --- + Body. + `)), 0o644)) + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{ + IO: ios, + Dir: dir, + DryRun: true, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "1 skill(s) validated successfully", + wantStderr: "Dry run complete", + }, + { + name: "namespaced skill discovered and validated", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + // Create a namespaced skill (skills/{scope}/*/SKILL.md convention) + skillDir := filepath.Join(dir, "skills", "monalisa", "scoped-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: scoped-skill + description: A namespaced skill + license: MIT + --- + Body. + `)), 0o644)) + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{ + IO: ios, + Dir: dir, + DryRun: true, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "1 skill(s) validated successfully", + wantStderr: "Dry run complete", + }, { name: "valid skill dry-run passes validation", isTTY: true, From e559a7cd5bf7789460f3fb4291524cb71b504fb4 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 15 Apr 2026 23:05:11 +0200 Subject: [PATCH 25/27] feat(skills): auto-push unpushed commits before publish Like gh pr create, skill publish now automatically pushes unpushed local commits before creating a release. This prevents the footgun where a release is created against stale remote state when the user has local commits that haven't been pushed yet. The ensurePushed function checks for unpushed commits using git rev-list @{push}..HEAD. If commits exist or the branch has never been pushed, it pushes automatically and prints a status message. This matches the CLI's opinionated-defaults philosophy of doing the right thing by default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/skills/publish/publish.go | 88 +++++++++++--- pkg/cmd/skills/publish/publish_test.go | 154 ++++++++++++++++++++++++- 2 files changed, 227 insertions(+), 15 deletions(-) diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index 931b9085c..85a3b68b9 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -10,6 +10,7 @@ import ( "path/filepath" "regexp" "sort" + "strconv" "strings" "github.com/MakeNowJust/heredoc" @@ -343,14 +344,14 @@ func publishRun(opts *PublishOptions) error { } owner, repo := "", "" if repoInfo != nil { - owner = repoInfo.RepoOwner() - repo = repoInfo.RepoName() + owner = repoInfo.Repo.RepoOwner() + repo = repoInfo.Repo.RepoName() } hasTopic := false var existingTags []tagEntry if owner != "" && repo != "" { if host == "" && repoInfo != nil { - host = repoInfo.RepoHost() + host = repoInfo.Repo.RepoHost() } if host != "" { if err := source.ValidateSupportedHost(host); err != nil { @@ -438,7 +439,7 @@ func publishRun(opts *PublishOptions) error { fmt.Fprintf(opts.IO.ErrOut, "\nPublishing to %s/%s...\n\n", owner, repo) - return runPublishRelease(opts, client, host, owner, repo, dir, hasTopic, existingTags) + return runPublishRelease(opts, client, host, owner, repo, dir, repoInfo.RemoteName, hasTopic, existingTags) } // repoHasTopic checks whether the repo has the agent-skills topic. @@ -473,11 +474,11 @@ func fetchTags(client *api.Client, host, owner, repo string) []tagEntry { } // runPublishRelease handles the interactive publish flow: topic, tag, release, immutability. -func runPublishRelease(opts *PublishOptions, client *api.Client, host, owner, repo, dir string, hasTopic bool, existingTags []tagEntry) error { +func runPublishRelease(opts *PublishOptions, client *api.Client, host, owner, repo, dir, remoteName string, hasTopic bool, existingTags []tagEntry) error { cs := opts.IO.ColorScheme() canPrompt := opts.IO.CanPrompt() - // 1. Add topic if missing + // Add topic if missing if !hasTopic { addTopic := true if canPrompt { @@ -498,7 +499,12 @@ func runPublishRelease(opts *PublishOptions, client *api.Client, host, owner, re } } - // 2. Determine tag + // Push unpushed commits (like gh pr create) + if err := ensurePushed(opts, dir, remoteName); err != nil { + return err + } + + // Determine tag tag := opts.Tag if tag == "" { suggested := "v1.0.0" @@ -549,7 +555,7 @@ func runPublishRelease(opts *PublishOptions, client *api.Client, host, owner, re } } - // 3. Offer to enable immutable releases + // Offer to enable immutable releases immutableEnabled := checkImmutableReleases(client, host, owner, repo) if !immutableEnabled && canPrompt { enableImmutable, err := opts.Prompter.Confirm( @@ -567,7 +573,7 @@ func runPublishRelease(opts *PublishOptions, client *api.Client, host, owner, re } } - // 4. Inform if not on default branch + // Inform if not on default branch var currentBranch string if opts.GitClient != nil { branchGitClient := opts.GitClient.Copy() @@ -581,7 +587,7 @@ func runPublishRelease(opts *PublishOptions, client *api.Client, host, owner, re fmt.Fprintf(opts.IO.ErrOut, "%s Publishing from branch %q (default is %q)\n", cs.WarningIcon(), currentBranch, defaultBranch) } - // 5. Confirm and create release + // Confirm and create release if canPrompt { confirmed, err := opts.Prompter.Confirm( fmt.Sprintf("Create release %s with auto-generated notes?", tag), true) @@ -622,6 +628,56 @@ func runPublishRelease(opts *PublishOptions, client *api.Client, host, owner, re return nil } +// ensurePushed checks whether the current branch has unpushed commits and +// pushes them automatically, consistent with how gh pr create behaves. +func ensurePushed(opts *PublishOptions, dir, remoteName string) error { + if opts.GitClient == nil { + return nil + } + + cs := opts.IO.ColorScheme() + gitClient := opts.GitClient.Copy() + gitClient.RepoDir = dir + + ctx := context.Background() + currentBranch, err := gitClient.CurrentBranch(ctx) + if err != nil { + return nil //nolint:nilerr // not on a branch (detached HEAD); skip push check + } + + // Count commits ahead of the push target (remote tracking branch). + // If the branch has no upstream, rev-list will fail; we treat that as + // "everything is unpushed" and push the whole branch. + unpushed := 0 + revCmd, err := gitClient.Command(ctx, "rev-list", "--count", "@{push}..HEAD") + if err != nil { + return fmt.Errorf("could not check unpushed commits: %w", err) + } + out, revErr := revCmd.Output() + if revErr != nil { + // @{push} not resolvable; branch has never been pushed + unpushed = -1 + } else { + n, parseErr := strconv.Atoi(strings.TrimSpace(string(out))) + if parseErr != nil { + return fmt.Errorf("could not parse unpushed commit count: %w", parseErr) + } + unpushed = n + } + + if unpushed == 0 { + return nil + } + + ref := fmt.Sprintf("HEAD:refs/heads/%s", currentBranch) + fmt.Fprintf(opts.IO.ErrOut, "%s Pushing %s to %s\n", cs.SuccessIcon(), currentBranch, remoteName) + if err := gitClient.Push(ctx, remoteName, ref); err != nil { + return fmt.Errorf("failed to push branch %s: %w", currentBranch, err) + } + + return nil +} + // detectDefaultBranch returns the default branch of the remote repo via the API. func detectDefaultBranch(client *api.Client, host, owner, repo string) string { if client == nil { @@ -866,9 +922,15 @@ func suggestNextTag(latest string) string { return fmt.Sprintf("%s%s.%s.%d", prefix, major, minor, patch+1) } +// gitHubRemote holds a detected GitHub remote and its local name. +type gitHubRemote struct { + Repo ghrepo.Interface + RemoteName string +} + // detectGitHubRemote attempts to detect the GitHub owner/repo from git remotes // in the given directory. -func detectGitHubRemote(gitClient *git.Client, dir string) (ghrepo.Interface, error) { +func detectGitHubRemote(gitClient *git.Client, dir string) (*gitHubRemote, error) { if gitClient == nil { return nil, nil } @@ -883,7 +945,7 @@ func detectGitHubRemote(gitClient *git.Client, dir string) (ghrepo.Interface, er return nil, parseErr } if repo != nil { - return repo, nil + return &gitHubRemote{Repo: repo, RemoteName: "origin"}, nil } } @@ -902,7 +964,7 @@ func detectGitHubRemote(gitClient *git.Client, dir string) (ghrepo.Interface, er return nil, parseErr } if repo != nil { - return repo, nil + return &gitHubRemote{Repo: repo, RemoteName: r.Name}, nil } } } diff --git a/pkg/cmd/skills/publish/publish_test.go b/pkg/cmd/skills/publish/publish_test.go index 6da0e01a4..82164a96a 100644 --- a/pkg/cmd/skills/publish/publish_test.go +++ b/pkg/cmd/skills/publish/publish_test.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" "github.com/MakeNowJust/heredoc" @@ -22,13 +23,28 @@ import ( // initGitRepo initializes a git repo in the given directory and adds remotes. // Use this when the git repo must live in the same directory as the skill files. +// A local bare repo is created as the push target so that ensurePushed can work +// during publish tests, while the fetch URL remains the GitHub URL so that +// detectGitHubRemote still resolves the correct owner/repo. func initGitRepo(t *testing.T, dir string, remoteURLs map[string]string) { t.Helper() + + bareDir := filepath.Join(t.TempDir(), "upstream.git") + require.NoError(t, os.MkdirAll(bareDir, 0o755)) + runGitInDir(t, bareDir, "init", "--bare", "--initial-branch=main") + runGitInDir(t, dir, "init", "--initial-branch=main") runGitInDir(t, dir, "config", "user.email", "monalisa@github.com") runGitInDir(t, dir, "config", "user.name", "Monalisa Octocat") for name, url := range remoteURLs { runGitInDir(t, dir, "remote", "add", name, url) + runGitInDir(t, dir, "remote", "set-url", "--push", name, bareDir) + } + + runGitInDir(t, dir, "add", ".") + runGitInDir(t, dir, "commit", "--allow-empty", "-m", "init") + if _, ok := remoteURLs["origin"]; ok { + runGitInDir(t, dir, "push", "origin", "main") } } @@ -1392,8 +1408,8 @@ func TestDetectGitHubRemote_UsesDir(t *testing.T) { repo, err := detectGitHubRemote(gitClient, targetRepo) require.NoError(t, err) require.NotNil(t, repo) - assert.Equal(t, "monalisa", repo.RepoOwner()) - assert.Equal(t, "target-repo", repo.RepoName()) + assert.Equal(t, "monalisa", repo.Repo.RepoOwner()) + assert.Equal(t, "target-repo", repo.Repo.RepoName()) } func TestPublishRun_DirArgUsesTargetRemote(t *testing.T) { @@ -1467,3 +1483,137 @@ func runGitInDir(t *testing.T, dir string, args ...string) { out, err := cmd.CombinedOutput() require.NoError(t, err, "git %v: %s", args, out) } + +// newTestGitClientWithUpstream creates a git repo with a local bare "remote" +// and an initial commit, so we can test push/rev-list behavior realistically. +// It returns the git client and the working directory path. +func newTestGitClientWithUpstream(t *testing.T) (*git.Client, string) { + t.Helper() + parentDir := t.TempDir() + bareDir := filepath.Join(parentDir, "upstream.git") + workDir := filepath.Join(parentDir, "work") + + gitEnv := append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+parentDir) + + run := func(dir string, args ...string) { + t.Helper() + c := exec.Command("git", append([]string{"-C", dir}, args...)...) + c.Env = gitEnv + out, err := c.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, out) + } + + // Create bare upstream + require.NoError(t, os.MkdirAll(bareDir, 0o755)) + run(bareDir, "init", "--bare", "--initial-branch=main") + + // Clone into working dir + c := exec.Command("git", "clone", bareDir, workDir) + c.Env = gitEnv + out, err := c.CombinedOutput() + require.NoError(t, err, "git clone: %s", out) + + run(workDir, "config", "user.email", "monalisa@github.com") + run(workDir, "config", "user.name", "Monalisa Octocat") + + // Create initial commit and push + require.NoError(t, os.WriteFile(filepath.Join(workDir, "README.md"), []byte("# Test"), 0o644)) + run(workDir, "add", ".") + run(workDir, "commit", "-m", "initial commit") + run(workDir, "push", "origin", "main") + + return &git.Client{ + RepoDir: workDir, + GitPath: "git", + Stderr: &bytes.Buffer{}, + Stdin: &bytes.Buffer{}, + Stdout: &bytes.Buffer{}, + }, workDir +} + +func TestEnsurePushed(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, workDir string) + verify func(t *testing.T, workDir string) + wantErr string + wantStderr string + }{ + { + name: "no unpushed commits is a no-op", + setup: func(_ *testing.T, _ string) { + // initial commit already pushed by helper + }, + }, + { + name: "unpushed commits are pushed automatically", + setup: func(t *testing.T, workDir string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(workDir, "new.txt"), []byte("new"), 0o644)) + runGitInDir(t, workDir, "add", ".") + runGitInDir(t, workDir, "commit", "-m", "unpushed change") + }, + verify: func(t *testing.T, workDir string) { + t.Helper() + // After push, rev-list should show 0 unpushed commits + cmd := exec.Command("git", "-C", workDir, "rev-list", "--count", "@{push}..HEAD") + cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+workDir) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "rev-list: %s", out) + assert.Equal(t, "0", strings.TrimSpace(string(out))) + }, + wantStderr: "Pushing main to origin", + }, + { + name: "new branch never pushed is pushed automatically", + setup: func(t *testing.T, workDir string) { + t.Helper() + runGitInDir(t, workDir, "checkout", "-b", "feature") + require.NoError(t, os.WriteFile(filepath.Join(workDir, "feat.txt"), []byte("feat"), 0o644)) + runGitInDir(t, workDir, "add", ".") + runGitInDir(t, workDir, "commit", "-m", "new branch commit") + }, + verify: func(t *testing.T, workDir string) { + t.Helper() + // After push, the branch should exist on the remote + cmd := exec.Command("git", "-C", workDir, "rev-list", "--count", "@{push}..HEAD") + cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+workDir) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "rev-list: %s", out) + assert.Equal(t, "0", strings.TrimSpace(string(out))) + }, + wantStderr: "Pushing feature to origin", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gitClient, workDir := newTestGitClientWithUpstream(t) + tt.setup(t, workDir) + + ios, _, _, stderr := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + opts := &PublishOptions{ + IO: ios, + GitClient: gitClient, + } + + err := ensurePushed(opts, workDir, "origin") + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + } + if tt.wantStderr != "" { + assert.Contains(t, stderr.String(), tt.wantStderr) + } + if tt.verify != nil { + tt.verify(t, workDir) + } + }) + } +} From 29e55fe5b9d578ca6b050d170c542b32eca5f569 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Thu, 16 Apr 2026 15:52:53 +0200 Subject: [PATCH 26/27] refactor: remove redundant nil-client fallback in skills publish (#13168) Remove the dead code block that silently swallowed errors from opts.HttpClient() and opts.Config() when creating an API client. Instead, create the client with proper error propagation inside the remote-checks block where it is actually needed. Changes: - Remove the error-swallowing client == nil fallback (lines 363-376) - Create the API client inside the remote repo checks block with proper error returns from HttpClient() and Config() - Resolve host from repoInfo first, then fall back to config - Remove the now-unreachable 'client == nil' early exit before publish Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/skills/publish/publish.go | 71 ++++++++++++++----------------- 1 file changed, 33 insertions(+), 38 deletions(-) diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index 9f981104f..213afeba5 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -178,7 +178,10 @@ func publishRun(opts *PublishOptions) error { canPrompt := opts.IO.CanPrompt() - // Use injected client or create one from the factory HttpClient + // Use injected client or create one from the factory HttpClient. + // Initialization is deferred until after local validation so that + // simple errors (missing skills/, bad SKILL.md, etc.) are reported + // without requiring an HTTP client. client := opts.client host := opts.host @@ -336,52 +339,49 @@ func publishRun(opts *PublishOptions) error { owner = repoInfo.Repo.RepoOwner() repo = repoInfo.Repo.RepoName() } + hasTopic := false var existingTags []tagEntry if owner != "" && repo != "" { + // Create API client from factory if not already injected (tests inject directly). + if client == nil { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + client = api.NewClientFromHTTP(httpClient) + } + if host == "" && repoInfo != nil { host = repoInfo.Repo.RepoHost() } - if host != "" { - if err := source.ValidateSupportedHost(host); err != nil { + if host == "" { + cfg, err := opts.Config() + if err != nil { return err } + host, _ = cfg.Authentication().DefaultHost() + } + if err := source.ValidateSupportedHost(host); err != nil { + return err } - // Create API client for remote checks if not already injected - if client == nil { - httpClient, httpErr := opts.HttpClient() - if httpErr == nil { - apiClient := api.NewClientFromHTTP(httpClient) - cfg, cfgErr := opts.Config() - if cfgErr == nil { - host, _ = cfg.Authentication().DefaultHost() - if err := source.ValidateSupportedHost(host); err != nil { - return err - } - client = apiClient - } - } + // Security and ruleset checks (advisory, always shown) + var skillAbsDirs []string + for _, skill := range skills { + skillAbsDirs = append(skillAbsDirs, filepath.Join(dir, filepath.FromSlash(skill.Path))) } + securityDiags := checkSecuritySettings(client, host, owner, repo, skillAbsDirs) + diagnostics = append(diagnostics, securityDiags...) - if client != nil { - // Security and ruleset checks (advisory, always shown) - var skillAbsDirs []string - for _, skill := range skills { - skillAbsDirs = append(skillAbsDirs, filepath.Join(dir, filepath.FromSlash(skill.Path))) - } - securityDiags := checkSecuritySettings(client, host, owner, repo, skillAbsDirs) - diagnostics = append(diagnostics, securityDiags...) + rulesetDiags := checkTagProtection(client, host, owner, repo) + diagnostics = append(diagnostics, rulesetDiags...) - rulesetDiags := checkTagProtection(client, host, owner, repo) - diagnostics = append(diagnostics, rulesetDiags...) + // Check topic (needed for publish flow, not a blocking error) + hasTopic = repoHasTopic(client, host, owner, repo) - // Check topic (needed for publish flow, not a blocking error) - hasTopic = repoHasTopic(client, host, owner, repo) - - // Fetch existing tags (needed for version suggestion) - existingTags = fetchTags(client, host, owner, repo) - } + // Fetch existing tags (needed for version suggestion) + existingTags = fetchTags(client, host, owner, repo) } else { diagnostics = append(diagnostics, detectMissingRepoDiagnostic(opts.GitClient, dir)...) } @@ -425,11 +425,6 @@ func publishRun(opts *PublishOptions) error { return nil } - if client == nil { - fmt.Fprintf(opts.IO.ErrOut, "\nValidation passed but could not create API client. Check your authentication configuration.\n") - return nil - } - fmt.Fprintf(opts.IO.ErrOut, "\nPublishing to %s/%s...\n\n", owner, repo) return runPublishRelease(opts, client, host, owner, repo, dir, repoInfo.RemoteName, hasTopic, existingTags) From 08a0c11d779a65e361c14bf85f116a04c21a84e4 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Thu, 16 Apr 2026 16:37:34 +0200 Subject: [PATCH 27/27] fix: update acceptance test to match current error message The skills-publish-dry-run acceptance test expected 'no skills/ directory found' on stderr, but the actual error message from discovery is 'no skills found in '. Update the stderr matcher accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- acceptance/testdata/skills/skills-publish-dry-run.txtar | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acceptance/testdata/skills/skills-publish-dry-run.txtar b/acceptance/testdata/skills/skills-publish-dry-run.txtar index 2dea21d67..cb32fa7e2 100644 --- a/acceptance/testdata/skills/skills-publish-dry-run.txtar +++ b/acceptance/testdata/skills/skills-publish-dry-run.txtar @@ -1,6 +1,6 @@ # Publish dry-run from a directory with no skills/ should fail gracefully ! exec gh skill publish --dry-run $WORK -stderr 'no skills/ directory found' +stderr 'no skills found in' # Publish dry-run against a valid skill directory should succeed exec gh skill publish --dry-run $WORK/test-repo