add core logic and improve test coverage

This commit is contained in:
tommaso-moro 2026-03-30 21:35:23 +01:00 committed by Sam Morrow
parent 758785b8f4
commit 40b2a784e3
No known key found for this signature in database
42 changed files with 6849 additions and 673 deletions

View file

@ -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'

View file

@ -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

View file

@ -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'

View file

@ -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'

View file

@ -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

View file

@ -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'

View file

@ -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

View file

@ -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

View file

@ -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'

View file

@ -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'

View file

@ -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!"

View file

@ -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."

View file

@ -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 .

View file

@ -0,0 +1,3 @@
# Pagination returns results on page 2
exec gh skills search copilot --page 2
stdout 'copilot'

View file

@ -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'

View file

@ -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 --

View file

@ -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

View file

@ -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...)

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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
}

View file

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

View file

@ -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
}

View file

@ -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(""))
}

View file

@ -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")
})
}

View file

@ -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 {

View file

@ -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)
}
})
}
}

View file

@ -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() {}
}

View file

@ -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
}

View file

@ -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 == "" {

View file

@ -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)
}
})
}
}

View file

@ -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] = &registry.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
}

View file

@ -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 := &registry.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 := &registry.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 := &registry.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 := &registry.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)")
}

View file

@ -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 <repository> [<skill>]",
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)
}
}
}

View file

@ -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 }

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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 <query>",
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:<owner> 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:<query> 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
}
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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 [<skill>...]",
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 <new-ref>" 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 := &registry.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
}

View file

@ -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 [<skill>...]", 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")
}