Merge pull request #13165 from cli/sm/add-skills-command

Add agent skills command
This commit is contained in:
Kynan Ware 2026-04-16 09:05:47 -06:00 committed by GitHub
commit 97af1a5eb7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 15303 additions and 2 deletions

1
.gitignore vendored
View file

@ -38,3 +38,4 @@
*~
vendor/
gh

View file

@ -14,9 +14,9 @@ import (
"math/rand"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/ghcmd"
"github.com/cli/go-internal/testscript"
"github.com/MakeNowJust/heredoc"
)
func ghMain() int {
@ -434,3 +434,11 @@ func (e *testScriptEnv) fromEnv() error {
return nil
}
func TestSkills(t *testing.T) {
var tsEnv testScriptEnv
if err := tsEnv.fromEnv(); err != nil {
t.Fatal(err)
}
testscript.Run(t, testScriptParamsFor(tsEnv, "skills"))
}

View file

@ -0,0 +1,11 @@
# Install with --force should overwrite an existing skill without error
exec gh skill install github/awesome-copilot git-commit --force --dir $WORK/force-test
stdout 'Installed git-commit'
# Install again with --force — should succeed (overwrite)
exec gh skill install github/awesome-copilot git-commit --force --dir $WORK/force-test
stdout 'Installed git-commit'
# Without --force, non-interactive should fail when skill exists
! exec gh skill install github/awesome-copilot git-commit --dir $WORK/force-test
stderr 'already installed'

View file

@ -0,0 +1,15 @@
# Install from a local directory using --from-local
exec gh skill install --from-local $WORK/local-repo git-commit --dir $WORK/output --force
stdout 'Installed git-commit'
# Verify the skill was copied
exists $WORK/output/git-commit/SKILL.md
grep 'local-path' $WORK/output/git-commit/SKILL.md
-- local-repo/skills/git-commit/SKILL.md --
---
name: git-commit
description: Write good git commits
---
# Git Commit
Body content.

View file

@ -0,0 +1,4 @@
# Invalid agent ID should error with valid options
! exec gh skill install github/awesome-copilot git-commit --agent bogus-agent --force
stderr 'invalid argument'
stderr 'github-copilot'

View file

@ -0,0 +1,3 @@
# Nonexistent repo should error
! exec gh skill install nonexistent-owner-xyz/nonexistent-repo-abc --force --dir $WORK/tmp
stderr 'Not Found'

View file

@ -0,0 +1,60 @@
# Two namespaced skills with the same base name in the same repo should
# be independently installable using path-based disambiguation.
# Use gh as a credential helper
exec gh auth setup-git
# Create a repo with two namespaced skills that share the name "deploy"
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --public --add-readme
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
cd $SCRIPT_NAME-$RANDOM_STRING
mkdir -p skills/alice/deploy
mkdir -p skills/bob/deploy
cp $WORK/alice-skill.md skills/alice/deploy/SKILL.md
cp $WORK/bob-skill.md skills/bob/deploy/SKILL.md
exec git add -A
exec git commit -m 'Add namespaced skills'
exec git push origin main
# Publish so the skills are discoverable
exec gh skill publish --tag v1.0.0
# Install alice's deploy skill using the full path to disambiguate
exec gh skill install $ORG/$SCRIPT_NAME-$RANDOM_STRING skills/alice/deploy --scope user --force
stdout 'Installed alice/deploy'
# Install bob's deploy skill using the full path
exec gh skill install $ORG/$SCRIPT_NAME-$RANDOM_STRING skills/bob/deploy --scope user --force
stdout 'Installed bob/deploy'
# Verify both were installed to separate directories
exists $HOME/.copilot/skills/alice/deploy/SKILL.md
exists $HOME/.copilot/skills/bob/deploy/SKILL.md
# Verify each has the correct content
grep 'Alice' $HOME/.copilot/skills/alice/deploy/SKILL.md
grep 'Bob' $HOME/.copilot/skills/bob/deploy/SKILL.md
-- alice-skill.md --
---
name: deploy
description: Alice's deployment skill
---
# Deploy by Alice
Deploys infrastructure using Alice's conventions.
-- bob-skill.md --
---
name: deploy
description: Bob's deployment skill
---
# Deploy by Bob
Deploys infrastructure using Bob's conventions.

View file

@ -0,0 +1,3 @@
# Install a skill that has nested subdirectories and verify file tree
exec gh skill 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 skill install github/awesome-copilot nonexistent-skill-xyz --force --dir $WORK/tmp
stderr 'not found'

View file

@ -0,0 +1,7 @@
# Install with --pin to a specific ref
exec gh skill install github/awesome-copilot git-commit --scope user --force --pin main
stdout 'Installed git-commit'
# Install without --pin should resolve latest version
exec gh skill install github/awesome-copilot git-commit --scope user --force
stdout 'Installed git-commit'

View file

@ -0,0 +1,9 @@
# Install with --scope project writes to the git repo's .agents/skills/
exec git init --initial-branch=main $WORK/myrepo
cd $WORK/myrepo
exec gh skill install github/awesome-copilot git-commit --scope project --force --agent github-copilot
exists $WORK/myrepo/.agents/skills/git-commit/SKILL.md
# Install with --scope user writes to home directory
exec gh skill install github/awesome-copilot git-commit --scope user --force --agent github-copilot
exists $HOME/.copilot/skills/git-commit/SKILL.md

View file

@ -0,0 +1,20 @@
# Install a single skill from a public repo
exec gh skill install github/awesome-copilot git-commit --scope user --force --agent github-copilot
stdout 'Installed git-commit'
# Verify SKILL.md has frontmatter metadata injected
exists $HOME/.copilot/skills/git-commit/SKILL.md
grep 'github-repo' $HOME/.copilot/skills/git-commit/SKILL.md
grep 'github-tree-sha' $HOME/.copilot/skills/git-commit/SKILL.md
# Verify lockfile was written
exists $HOME/.agents/.skill-lock.json
grep 'git-commit' $HOME/.agents/.skill-lock.json
# Install with --dir to a custom directory
exec gh skill install github/awesome-copilot git-commit --force --dir $WORK/custom-skills
stdout 'Installed git-commit'
# Verify the skill was written to the custom directory
exists $WORK/custom-skills/git-commit/SKILL.md
grep 'github-repo' $WORK/custom-skills/git-commit/SKILL.md

View file

@ -0,0 +1,3 @@
# Preview with repo only and non-interactive should error
! exec gh skill 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 skill preview github/awesome-copilot git-commit
stdout 'SKILL.md'
# Verify actual content is rendered, not just the filename
stdout 'git-commit/'
# Preview a skill that doesn't exist should error
! exec gh skill preview github/awesome-copilot nonexistent-skill-xyz
stderr 'not found'

View file

@ -0,0 +1,58 @@
# When a directory argument is provided to `gh skill publish --dry-run`,
# the remote detection must use the target directory's git remotes,
# not the current working directory's remotes.
#
# This test creates two separate git repos:
# - cwd-repo (the working directory) with remote pointing to owner/cwd-repo
# - target-repo (the dir argument) with remote pointing to owner/target-repo
#
# If the bug is present, the command would detect cwd-repo's remote instead of
# target-repo's remote.
# Set up credential helper
exec gh auth setup-git
# Create two test repos on GitHub
exec gh repo create $ORG/$SCRIPT_NAME-cwd-$RANDOM_STRING --private --add-readme
defer gh repo delete --yes $ORG/$SCRIPT_NAME-cwd-$RANDOM_STRING
exec gh repo create $ORG/$SCRIPT_NAME-target-$RANDOM_STRING --private --add-readme
defer gh repo delete --yes $ORG/$SCRIPT_NAME-target-$RANDOM_STRING
# Clone both repos
exec gh repo clone $ORG/$SCRIPT_NAME-cwd-$RANDOM_STRING cwd-repo
exec gh repo clone $ORG/$SCRIPT_NAME-target-$RANDOM_STRING target-repo
# Add a skill to the target repo only
mkdir target-repo/skills/hello-world
cp $WORK/skill.md target-repo/skills/hello-world/SKILL.md
exec git -C $WORK/target-repo add -A
exec git -C $WORK/target-repo commit -m 'Add test skill'
exec git -C $WORK/target-repo push origin main
# Run publish dry-run from cwd-repo, pointing at target-repo
cd cwd-repo
exec gh skill publish --dry-run $WORK/target-repo
# Verify the output references the target repo, not the cwd repo
stdout 'hello-world'
# Publish with a tag from within cwd-repo, targeting target-repo
exec gh skill publish --tag v0.1.0 $WORK/target-repo
# Verify the release was created on the TARGET repo, not the cwd repo
exec gh release view v0.1.0 --repo $ORG/$SCRIPT_NAME-target-$RANDOM_STRING
stdout 'v0.1.0'
# Verify NO release was created on the cwd repo
! exec gh release view v0.1.0 --repo $ORG/$SCRIPT_NAME-cwd-$RANDOM_STRING
-- skill.md --
---
name: hello-world
description: A test skill that greets the user.
---
# Hello World
Greet the user warmly.

View file

@ -0,0 +1,33 @@
# Publish dry-run from a directory with no skills/ should fail gracefully
! exec gh skill publish --dry-run $WORK
stderr 'no skills found in'
# Publish dry-run against a valid skill directory should succeed
exec gh skill publish --dry-run $WORK/test-repo
stdout 'hello-world'
# Validate alias should work identically
exec gh skill validate --dry-run $WORK/test-repo
stdout 'hello-world'
# Publish dry-run with --tag
exec gh skill publish --dry-run --tag v1.0.0 $WORK/test-repo
stdout 'hello-world'
# Publish dry-run with --fix
exec gh skill 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 skill publish --tag v0.1.0
# Verify the release was created on GitHub
exec gh release view v0.1.0
stdout 'v0.1.0'
# Install from our test repo
exec gh skill install $ORG/$SCRIPT_NAME-$RANDOM_STRING hello-world --scope user --force
stdout 'Installed hello-world'
# Verify installed files exist with correct metadata
exists $HOME/.copilot/skills/hello-world/SKILL.md
exists $HOME/.copilot/skills/hello-world/scripts/setup.sh
grep 'github-repo' $HOME/.copilot/skills/hello-world/SKILL.md
# Install with --pin
exec gh skill install $ORG/$SCRIPT_NAME-$RANDOM_STRING hello-world --scope user --force --pin v0.1.0
stdout 'Installed hello-world'
# Preview from our test repo
exec gh skill preview $ORG/$SCRIPT_NAME-$RANDOM_STRING hello-world
stdout 'Hello World'
# Update dry-run should find installed skill
exec gh skill 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 skill search zzzznonexistenttotallyfakeskillxyz123
! stdout .

View file

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

View file

@ -0,0 +1,12 @@
# Search for skills matching a query
exec gh skill search copilot
stdout 'copilot'
# Search with JSON output
exec gh skill search copilot --json skillName,repo --limit 1
stdout '"skillName"'
stdout '"repo"'
# Search with a short query should error
! exec gh skill search a
stderr 'at least'

View file

@ -0,0 +1,5 @@
# Update with no installed skills should report appropriately
exec gh skill update --dry-run --all --dir $WORK/empty-dir
stderr 'No installed skills found'
-- empty-dir/.gitkeep --

View file

@ -0,0 +1,22 @@
# Dry-run update should find the installed skill and report status
exec gh skill update --dry-run --all --dir $WORK/skills-dir
stdout 'git-commit'
# Force update should re-download and rewrite files
exec gh skill update --force --all --dir $WORK/skills-dir
stdout 'Updated'
# Verify the SKILL.md was rewritten with real content (not our placeholder)
grep 'github-repo' $WORK/skills-dir/git-commit/SKILL.md
! grep 'Test skill content' $WORK/skills-dir/git-commit/SKILL.md
-- skills-dir/git-commit/SKILL.md --
---
name: git-commit
description: Git commit helper
metadata:
github-repo: https://github.com/github/awesome-copilot.git
github-tree-sha: 0000000000000000000000000000000000000000
github-path: skills/git-commit
---
Test skill content

View file

@ -713,6 +713,47 @@ 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.
// Returns an error for fatal git failures (e.g. path outside repository).
func (c *Client) IsIgnored(ctx context.Context, path string) (bool, error) {
cmd, err := c.Command(ctx, "check-ignore", "-q", "--", path)
if err != nil {
return false, err
}
_, err = cmd.Output()
if err == nil {
return true, nil
}
// Exit 1 here means we can confirm the path is not ignored.
// Any other error is a real git error.
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
return false, nil
}
return false, err
}
// ShortSHA returns the first 8 characters of a SHA hash for display purposes.
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

@ -2164,3 +2164,123 @@ func createMockedCommandContext(t *testing.T, commands mockedCommands) commandCt
return cmd
}
}
func TestClientRemoteURL(t *testing.T) {
tests := []struct {
name string
cmdExitStatus int
cmdStdout string
cmdStderr string
wantCmdArgs string
wantURL string
wantErrorMsg string
}{
{
name: "returns remote URL",
cmdStdout: "https://github.com/monalisa/skills-repo.git\n",
wantCmdArgs: "path/to/git remote get-url -- origin",
wantURL: "https://github.com/monalisa/skills-repo.git",
},
{
name: "git error",
cmdExitStatus: 1,
cmdStderr: "fatal: No such remote 'nonexistent'",
wantCmdArgs: "path/to/git remote get-url -- nonexistent",
wantErrorMsg: "failed to run git: fatal: No such remote 'nonexistent'",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr)
client := Client{
GitPath: "path/to/git",
commandContext: cmdCtx,
}
remoteName := "origin"
if tt.wantErrorMsg != "" {
remoteName = "nonexistent"
}
url, err := client.RemoteURL(context.Background(), remoteName)
assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " "))
if tt.wantErrorMsg == "" {
assert.NoError(t, err)
assert.Equal(t, tt.wantURL, url)
} else {
assert.EqualError(t, err, tt.wantErrorMsg)
}
})
}
// Covers the early return in RemoteURL when Command() itself fails.
// (e.g. git binary not resolvable).
t.Run("returns error when git has a fatal error", func(t *testing.T) {
t.Setenv("PATH", "")
client := Client{}
_, err := client.RemoteURL(context.Background(), "origin")
assert.Error(t, err)
})
}
func TestClientIsIgnored(t *testing.T) {
tests := []struct {
name string
cmdExitStatus int
cmdStdout string
cmdStderr string
wantCmdArgs string
wantIgnored bool
wantErr bool
}{
{
name: "path is ignored",
wantCmdArgs: "path/to/git check-ignore -q -- .github/skills",
wantIgnored: true,
},
{
name: "path is not ignored",
cmdExitStatus: 1,
wantCmdArgs: "path/to/git check-ignore -q -- .github/skills",
wantIgnored: false,
},
{
name: "fatal git error",
cmdExitStatus: 128,
cmdStderr: "fatal: not a git repository",
wantCmdArgs: "path/to/git check-ignore -q -- .github/skills",
wantIgnored: false,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr)
client := Client{
GitPath: "path/to/git",
commandContext: cmdCtx,
}
ignored, err := client.IsIgnored(context.Background(), ".github/skills")
assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " "))
assert.Equal(t, tt.wantIgnored, ignored)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
// Covers the early return in IsIgnored when Command() itself fails
// (e.g. git binary not resolvable).
t.Run("returns error when git has a fatal error", func(t *testing.T) {
t.Setenv("PATH", "")
client := Client{}
ignored, err := client.IsIgnored(context.Background(), ".github/skills")
assert.False(t, ignored)
assert.Error(t, err)
})
}
func TestShortSHA(t *testing.T) {
assert.Equal(t, "abc123de", ShortSHA("abc123def456789"))
assert.Equal(t, "short", ShortSHA("short"))
}

2
go.mod
View file

@ -57,6 +57,7 @@ require (
github.com/zalando/go-keyring v0.2.8
golang.org/x/crypto v0.50.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.43.0
golang.org/x/term v0.42.0
golang.org/x/text v0.36.0
google.golang.org/grpc v1.80.0
@ -182,7 +183,6 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/tools v0.43.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect

8
internal/flock/flock.go Normal file
View file

@ -0,0 +1,8 @@
package flock
import "errors"
// ErrLocked is returned when the file is already locked by another process.
// Callers can check for this to distinguish contention from permanent errors.
// This is intended to be an OS-agnostic sentinel error.
var ErrLocked = errors.New("file is locked by another process")

View file

@ -0,0 +1,99 @@
package flock_test
import (
"os"
"path/filepath"
"testing"
"github.com/cli/cli/v2/internal/flock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTryLock(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T) string // returns lock path
wantErr error
verify func(t *testing.T, f *os.File)
}{
{
name: "acquires lock and returns writable file handle",
setup: func(t *testing.T) string {
return filepath.Join(t.TempDir(), "test.lock")
},
verify: func(t *testing.T, f *os.File) {
t.Helper()
_, err := f.WriteString("hello")
require.NoError(t, err)
_, err = f.Seek(0, 0)
require.NoError(t, err)
buf := make([]byte, 5)
n, err := f.Read(buf)
assert.NoError(t, err)
assert.Equal(t, "hello", string(buf[:n]))
},
},
{
name: "creates lock file if it does not exist",
setup: func(t *testing.T) string {
dir := filepath.Join(t.TempDir(), "subdir")
require.NoError(t, os.MkdirAll(dir, 0o755))
return filepath.Join(dir, "new.lock")
},
verify: func(t *testing.T, f *os.File) {
t.Helper()
_, err := os.Stat(f.Name())
assert.NoError(t, err)
},
},
{
name: "second lock on same path returns ErrLocked",
setup: func(t *testing.T) string {
lockPath := filepath.Join(t.TempDir(), "contended.lock")
_, unlock, err := flock.TryLock(lockPath)
require.NoError(t, err)
t.Cleanup(unlock)
return lockPath
},
wantErr: flock.ErrLocked,
},
{
name: "lock succeeds after unlock",
setup: func(t *testing.T) string {
lockPath := filepath.Join(t.TempDir(), "reuse.lock")
_, unlock, err := flock.TryLock(lockPath)
require.NoError(t, err)
unlock()
return lockPath
},
},
{
name: "fails on non-existent directory",
setup: func(t *testing.T) string {
return filepath.Join(t.TempDir(), "no", "such", "dir", "test.lock")
},
wantErr: os.ErrNotExist,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
lockPath := tt.setup(t)
f, unlock, err := flock.TryLock(lockPath)
if tt.wantErr != nil {
require.ErrorIs(t, err, tt.wantErr)
return
}
require.NoError(t, err)
require.NotNil(t, f)
defer unlock()
if tt.verify != nil {
tt.verify(t, f)
}
})
}
}

View file

@ -0,0 +1,32 @@
//go:build !windows
package flock
import (
"errors"
"os"
"syscall"
)
// TryLock attempts to acquire an exclusive, non-blocking flock on the given path.
// Returns the locked file and an unlock function on success. The caller should
// read/write through the returned file to avoid platform differences with
// mandatory locking on Windows.
// Returns ErrLocked if the file is already locked by another process.
func TryLock(path string) (f *os.File, unlock func(), err error) {
f, err = os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644)
if err != nil {
return nil, nil, err
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
_ = f.Close()
if errors.Is(err, syscall.EWOULDBLOCK) {
return nil, nil, ErrLocked
}
return nil, nil, err
}
return f, func() {
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
_ = f.Close()
}, nil
}

View file

@ -0,0 +1,41 @@
//go:build windows
package flock
import (
"errors"
"os"
"golang.org/x/sys/windows"
)
// TryLock attempts to acquire an exclusive, non-blocking lock on the given path.
// Returns the locked file and an unlock function on success. The caller should
// read/write through the returned file to avoid Windows mandatory lock conflicts.
// Returns ErrLocked if the file is already locked by another process.
func TryLock(path string) (f *os.File, unlock func(), err error) {
f, err = os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644)
if err != nil {
return nil, nil, err
}
ol := new(windows.Overlapped)
handle := windows.Handle(f.Fd())
err = windows.LockFileEx(
handle,
windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY,
0,
1, 0,
ol,
)
if err != nil {
_ = f.Close()
if errors.Is(err, windows.ERROR_LOCK_VIOLATION) {
return nil, nil, ErrLocked
}
return nil, nil, err
}
return f, func() {
_ = windows.UnlockFileEx(handle, 0, 1, 0, ol)
_ = f.Close()
}, nil
}

View file

@ -0,0 +1,53 @@
package discovery
import (
"fmt"
"sort"
"strings"
)
// NameCollision represents a group of skills that share the same InstallName
// and would overwrite each other when installed to the same directory.
type NameCollision struct {
Name string // the conflicting install name (may include namespace prefix)
DisplayNames []string // display names of each conflicting skill
}
// FindNameCollisions detects skills that share the same InstallName and returns a
// sorted slice of collisions. Callers decide how to present the conflict to
// the user (different flows need different error messages).
func FindNameCollisions(skills []Skill) []NameCollision {
byName := make(map[string][]Skill)
for _, s := range skills {
byName[s.InstallName()] = append(byName[s.InstallName()], s)
}
var collisions []NameCollision
for name, group := range byName {
if len(group) <= 1 {
continue
}
names := make([]string, len(group))
for i, s := range group {
names[i] = s.DisplayName()
}
collisions = append(collisions, NameCollision{Name: name, DisplayNames: names})
}
sort.Slice(collisions, func(i, j int) bool {
return collisions[i].Name < collisions[j].Name
})
return collisions
}
// FormatCollisions builds a human-readable string listing each collision,
// suitable for embedding in an error message. Each collision is formatted as
// "name: display1, display2" and collisions are separated by newlines with
// leading indentation.
func FormatCollisions(collisions []NameCollision) string {
lines := make([]string, len(collisions))
for i, c := range collisions {
lines[i] = fmt.Sprintf("%s: %s", c.Name, strings.Join(c.DisplayNames, ", "))
}
return strings.Join(lines, "\n ")
}

View file

@ -0,0 +1,80 @@
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 with different conventions",
skills: []Skill{
{Name: "pr-summary", Path: "skills/pr-summary"},
{Name: "pr-summary", Path: "plugins/hubot/skills/pr-summary", Convention: "plugins"},
},
want: []NameCollision{
{Name: "pr-summary", DisplayNames: []string{"pr-summary", "[plugins] 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) {
tests := []struct {
name string
collisions []NameCollision
want string
}{
{
name: "formats multiple collisions",
collisions: []NameCollision{
{Name: "pr-summary", DisplayNames: []string{"skills/pr-summary", "plugins/hubot/pr-summary"}},
{Name: "code-review", DisplayNames: []string{"skills/code-review", "skills/monalisa/code-review"}},
},
want: "pr-summary: skills/pr-summary, plugins/hubot/pr-summary\n code-review: skills/code-review, skills/monalisa/code-review",
},
{
name: "nil input returns empty string",
collisions: nil,
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, FormatCollisions(tt.collisions))
})
}
}

View file

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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,149 @@
package frontmatter
import (
"bytes"
"fmt"
"strings"
"github.com/cli/cli/v2/internal/skills/source"
"gopkg.in/yaml.v3"
)
const delimiter = "---"
// Metadata represents the parsed YAML frontmatter of a SKILL.md file.
type Metadata struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
License string `yaml:"license,omitempty"`
Meta map[string]interface{} `yaml:"metadata,omitempty"`
}
// ParseResult contains the parsed frontmatter and remaining body.
type ParseResult struct {
Metadata Metadata
Body string
RawYAML map[string]interface{}
}
// Parse extracts YAML frontmatter from a SKILL.md file.
// Frontmatter is delimited by --- on its own lines.
func Parse(content string) (*ParseResult, error) {
trimmed := strings.TrimLeft(content, "\r\n")
if !strings.HasPrefix(trimmed, delimiter) {
return &ParseResult{Body: content}, nil
}
rest := trimmed[len(delimiter):]
rest = strings.TrimLeft(rest, "\r\n")
endIdx := strings.Index(rest, "\n"+delimiter)
if endIdx == -1 {
return &ParseResult{Body: content}, nil
}
yamlContent := rest[:endIdx]
body := rest[endIdx+len("\n"+delimiter):]
body = strings.TrimLeft(body, "\r\n")
var rawYAML map[string]interface{}
if err := yaml.Unmarshal([]byte(yamlContent), &rawYAML); err != nil {
return nil, fmt.Errorf("invalid frontmatter YAML: %w", err)
}
var meta Metadata
if err := yaml.Unmarshal([]byte(yamlContent), &meta); err != nil {
return nil, fmt.Errorf("invalid frontmatter YAML: %w", err)
}
return &ParseResult{
Metadata: meta,
Body: body,
RawYAML: rawYAML,
}, nil
}
// InjectGitHubMetadata adds GitHub tracking metadata to the spec-defined
// "metadata" map in frontmatter. Keys are prefixed with "github-" to avoid
// collisions with other tools' metadata.
// pinnedRef is the user's explicit --pin value; empty string means unpinned.
// skillPath is the skill's source path in the repo (e.g. "skills/author/my-skill").
func InjectGitHubMetadata(content string, host, owner, repo, ref, treeSHA, pinnedRef, skillPath string) (string, error) {
result, err := Parse(content)
if err != nil {
return "", err
}
if result.RawYAML == nil {
result.RawYAML = make(map[string]interface{})
}
meta, _ := result.RawYAML["metadata"].(map[string]interface{})
if meta == nil {
meta = make(map[string]interface{})
}
delete(meta, "github-owner")
meta["github-repo"] = source.BuildRepoURL(host, owner, repo)
meta["github-ref"] = ref
delete(meta, "github-sha")
meta["github-tree-sha"] = treeSHA
meta["github-path"] = skillPath
if pinnedRef != "" {
meta["github-pinned"] = pinnedRef
} else {
delete(meta, "github-pinned")
}
result.RawYAML["metadata"] = meta
return Serialize(result.RawYAML, result.Body)
}
// InjectLocalMetadata adds local-source tracking metadata to frontmatter.
// sourcePath is the absolute path to the source skill directory.
func InjectLocalMetadata(content string, sourcePath string) (string, error) {
result, err := Parse(content)
if err != nil {
return "", err
}
if result.RawYAML == nil {
result.RawYAML = make(map[string]interface{})
}
meta, _ := result.RawYAML["metadata"].(map[string]interface{})
if meta == nil {
meta = make(map[string]interface{})
}
delete(meta, "github-owner")
delete(meta, "github-repo")
delete(meta, "github-ref")
delete(meta, "github-sha")
delete(meta, "github-tree-sha")
delete(meta, "github-pinned")
delete(meta, "github-path")
meta["local-path"] = sourcePath
result.RawYAML["metadata"] = meta
return Serialize(result.RawYAML, result.Body)
}
// Serialize writes a frontmatter map and body back to a SKILL.md string.
func Serialize(frontmatter map[string]interface{}, body string) (string, error) {
var buf bytes.Buffer
yamlBytes, err := yaml.Marshal(frontmatter)
if err != nil {
return "", fmt.Errorf("failed to serialize frontmatter: %w", err)
}
buf.WriteString(delimiter + "\n")
buf.Write(yamlBytes)
buf.WriteString(delimiter + "\n")
if body != "" {
buf.WriteString(body)
if !strings.HasSuffix(body, "\n") {
buf.WriteString("\n")
}
}
return buf.String(), nil
}

View file

@ -0,0 +1,255 @@
package frontmatter
import (
"strings"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParse(t *testing.T) {
tests := []struct {
name string
content string
wantName string
wantDesc string
wantBody string
wantErr bool
}{
{
name: "valid frontmatter",
content: heredoc.Doc(`
---
name: test-skill
description: A test skill
---
# Body
`),
wantName: "test-skill",
wantDesc: "A test skill",
wantBody: "# Body\n",
},
{
name: "no frontmatter",
content: "# Just a markdown file\n",
wantBody: "# Just a markdown file\n",
},
{
name: "invalid YAML",
content: "---\n: invalid yaml [[\n---\n",
wantErr: true,
},
{
name: "no closing delimiter",
content: "---\nname: test\n",
wantBody: "---\nname: test\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := Parse(tt.content)
if tt.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantName, result.Metadata.Name)
assert.Equal(t, tt.wantDesc, result.Metadata.Description)
assert.Equal(t, tt.wantBody, result.Body)
})
}
}
func TestInjectGitHubMetadata(t *testing.T) {
tests := []struct {
name string
content string
host string
owner string
repo string
ref string
treeSHA string
pinnedRef string
skillPath string
wantContains []string
wantNotContain []string
}{
{
name: "injects metadata without pin",
content: heredoc.Doc(`
---
name: my-skill
description: desc
---
# Body
`),
host: "github.com",
owner: "monalisa",
repo: "octocat-skills",
ref: "refs/tags/v1.0.0",
treeSHA: "tree456",
pinnedRef: "",
skillPath: "skills/my-skill",
wantContains: []string{
"github-repo: https://github.com/monalisa/octocat-skills",
"github-ref: refs/tags/v1.0.0",
"github-tree-sha: tree456",
"github-path: skills/my-skill",
"# Body",
},
wantNotContain: []string{
"github-owner",
"github-sha",
"github-pinned",
},
},
{
name: "injects pinned ref",
content: heredoc.Doc(`
---
name: my-skill
---
# Body
`),
host: "github.com",
owner: "monalisa",
repo: "octocat-skills",
ref: "refs/tags/v1.0.0",
treeSHA: "tree",
pinnedRef: "v1.0.0",
skillPath: "skills/my-skill",
wantContains: []string{
"github-pinned: v1.0.0",
},
},
{
name: "injects metadata into content with no frontmatter",
content: "# Body only\n",
host: "github.com",
owner: "monalisa",
repo: "octocat-skills",
ref: "refs/heads/main",
treeSHA: "tree456",
pinnedRef: "",
skillPath: "skills/my-skill",
wantContains: []string{
"github-repo: https://github.com/monalisa/octocat-skills",
"github-ref: refs/heads/main",
"# Body only",
},
wantNotContain: []string{"github-owner", "github-sha"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := InjectGitHubMetadata(tt.content, tt.host, tt.owner, tt.repo, tt.ref, tt.treeSHA, tt.pinnedRef, tt.skillPath)
require.NoError(t, err)
for _, s := range tt.wantContains {
assert.Contains(t, got, s)
}
for _, s := range tt.wantNotContain {
assert.NotContains(t, got, s)
}
})
}
}
func TestInjectLocalMetadata(t *testing.T) {
tests := []struct {
name string
content string
wantContains []string
wantNotContain []string
}{
{
name: "strips all github keys and injects local-path",
content: heredoc.Doc(`
---
name: my-skill
metadata:
github-owner: old
github-repo: old
github-ref: v1.0.0
github-sha: abc123
github-tree-sha: tree456
github-pinned: v1.0.0
github-path: skills/my-skill
---
# Body
`),
wantContains: []string{"local-path: /home/monalisa/skills/my-skill"},
wantNotContain: []string{"github-owner", "github-repo", "github-ref", "github-sha", "github-tree-sha", "github-pinned", "github-path"},
},
{
name: "injects into content with no existing metadata",
content: "# Body only\n",
wantContains: []string{"local-path: /home/monalisa/skills/my-skill"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := InjectLocalMetadata(tt.content, "/home/monalisa/skills/my-skill")
require.NoError(t, err)
for _, s := range tt.wantContains {
assert.Contains(t, got, s)
}
for _, s := range tt.wantNotContain {
assert.NotContains(t, got, s)
}
})
}
}
func TestSerialize(t *testing.T) {
tests := []struct {
name string
frontmatter map[string]interface{}
body string
wantPrefix string
wantSuffix string
wantContains []string
}{
{
name: "with body",
frontmatter: map[string]interface{}{"name": "test"},
body: "# Body content",
wantPrefix: "---\n",
wantContains: []string{
"name: test",
"# Body content",
},
},
{
name: "empty body",
frontmatter: map[string]interface{}{"name": "test"},
body: "",
wantSuffix: "---\n",
},
{
name: "body without trailing newline gets one added",
frontmatter: map[string]interface{}{"name": "test"},
body: "# No trailing newline",
wantSuffix: "# No trailing newline\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Serialize(tt.frontmatter, tt.body)
require.NoError(t, err)
if tt.wantPrefix != "" {
assert.True(t, strings.HasPrefix(got, tt.wantPrefix))
}
if tt.wantSuffix != "" {
assert.True(t, strings.HasSuffix(got, tt.wantSuffix))
}
for _, s := range tt.wantContains {
assert.Contains(t, got, s)
}
})
}
}

View file

@ -0,0 +1,327 @@
package installer
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/safepaths"
"github.com/cli/cli/v2/internal/skills/discovery"
"github.com/cli/cli/v2/internal/skills/frontmatter"
"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.
const maxConcurrency = 5
// Options configures an installation.
type Options struct {
Host string // GitHub API hostname
Owner string
Repo string
Ref string // resolved ref name
SHA string // resolved commit SHA
PinnedRef string // user-supplied --pin value (empty if unpinned)
Skills []discovery.Skill
AgentHost *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 *api.Client
OnProgress func(done, total int) // called after each skill is installed
}
// Result tracks what was installed.
type Result struct {
Installed []string
Dir string
Warnings []string
}
type skillResult struct {
name string
err error
}
// Install fetches and writes skills to the target directory.
func Install(opts *Options) (*Result, error) {
targetDir := opts.Dir
if targetDir == "" {
if opts.AgentHost == nil {
return nil, fmt.Errorf("either Dir or AgentHost must be specified")
}
var err error
targetDir, err = opts.AgentHost.InstallDir(opts.Scope, opts.GitRoot, opts.HomeDir)
if err != nil {
return nil, err
}
}
if len(opts.Skills) == 1 {
skill := opts.Skills[0]
if opts.OnProgress != nil {
opts.OnProgress(0, 1)
defer opts.OnProgress(1, 1)
}
if err := installSkill(opts, skill, targetDir); err != nil {
return nil, fmt.Errorf("failed to install skill %q: %w", skill.InstallName(), err)
}
var warnings []string
if err := lockfile.RecordInstall(skill.InstallName(), opts.Owner, opts.Repo, skill.Path+"/SKILL.md", skill.TreeSHA, opts.PinnedRef); err != nil {
warnings = append(warnings, fmt.Sprintf("could not record install for %s: %v", skill.InstallName(), err))
}
return &Result{Installed: []string{skill.InstallName()}, Dir: targetDir, Warnings: warnings}, nil
}
total := len(opts.Skills)
if opts.OnProgress != nil {
opts.OnProgress(0, total)
}
type job struct {
idx int
skill discovery.Skill
}
jobs := make(chan job)
results := make([]skillResult, total)
var wg sync.WaitGroup
var done atomic.Int32
workers := min(maxConcurrency, total)
for range workers {
wg.Go(func() {
for j := range jobs {
err := installSkill(opts, j.skill, targetDir)
results[j.idx] = skillResult{name: j.skill.InstallName(), err: err}
if opts.OnProgress != nil {
opts.OnProgress(int(done.Add(1)), total)
}
}
})
}
for i, s := range opts.Skills {
jobs <- job{idx: i, skill: s}
}
close(jobs)
wg.Wait()
var installed []string
var warnings []string
var firstErr error
for i, r := range results {
if r.err != nil {
if firstErr == nil {
firstErr = fmt.Errorf("failed to install skill %q: %w", r.name, r.err)
}
continue
}
installed = append(installed, r.name)
skill := opts.Skills[i]
if err := lockfile.RecordInstall(skill.InstallName(), opts.Owner, opts.Repo, skill.Path+"/SKILL.md", skill.TreeSHA, opts.PinnedRef); err != nil {
warnings = append(warnings, fmt.Sprintf("could not record install for %s: %v", skill.InstallName(), err))
}
}
if firstErr != nil {
return &Result{Installed: installed, Dir: targetDir, Warnings: warnings}, firstErr
}
return &Result{Installed: installed, Dir: targetDir, Warnings: warnings}, nil
}
// LocalOptions configures a local directory installation.
type LocalOptions struct {
SourceDir string
Skills []discovery.Skill
AgentHost *registry.AgentHost
Scope registry.Scope
Dir string
GitRoot string
HomeDir string
}
// InstallLocal copies skills from a local directory to the target install location.
func InstallLocal(opts *LocalOptions) (*Result, error) {
targetDir := opts.Dir
if targetDir == "" {
if opts.AgentHost == nil {
return nil, fmt.Errorf("either Dir or AgentHost must be specified")
}
var err error
targetDir, err = opts.AgentHost.InstallDir(opts.Scope, opts.GitRoot, opts.HomeDir)
if err != nil {
return nil, err
}
}
var installed []string
for _, skill := range opts.Skills {
if err := installLocalSkill(opts.SourceDir, skill, targetDir); err != nil {
return nil, fmt.Errorf("failed to install skill %q: %w", skill.InstallName(), err)
}
installed = append(installed, skill.InstallName())
}
return &Result{Installed: installed, Dir: targetDir}, nil
}
func installLocalSkill(sourceRoot string, skill discovery.Skill, baseDir string) error {
skillDir := filepath.Join(baseDir, filepath.FromSlash(skill.InstallName()))
if err := os.MkdirAll(skillDir, 0o755); err != nil {
return fmt.Errorf("could not create directory %s: %w", skillDir, err)
}
srcDir := filepath.Join(sourceRoot, filepath.FromSlash(skill.Path))
absSource, err := filepath.Abs(srcDir)
if err != nil {
return fmt.Errorf("could not resolve source path: %w", err)
}
safeSkillDir, err := safepaths.ParseAbsolute(skillDir)
if err != nil {
return fmt.Errorf("could not resolve target path: %w", err)
}
return filepath.WalkDir(srcDir, func(p string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.Type()&os.ModeSymlink != 0 {
return nil
}
if d.IsDir() {
return nil
}
relPath, err := filepath.Rel(srcDir, p)
if err != nil {
return err
}
// 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 fmt.Errorf("blocked path traversal in %q", relPath)
}
return fmt.Errorf("could not resolve destination path: %w", err)
}
destPath := safeDest.String()
if dir := filepath.Dir(destPath); dir != skillDir {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("could not create directory: %w", err)
}
}
content, err := os.ReadFile(p)
if err != nil {
return fmt.Errorf("could not read %s: %w", p, err)
}
if filepath.Base(relPath) == "SKILL.md" {
injected, injectErr := frontmatter.InjectLocalMetadata(string(content), absSource)
if injectErr != nil {
return fmt.Errorf("could not inject metadata: %w", injectErr)
}
content = []byte(injected)
}
return os.WriteFile(destPath, content, 0o644)
})
}
func installSkill(opts *Options, skill discovery.Skill, baseDir string) error {
skillDir := filepath.Join(baseDir, filepath.FromSlash(skill.InstallName()))
if err := os.MkdirAll(skillDir, 0o755); err != nil {
return fmt.Errorf("could not create directory %s: %w", skillDir, err)
}
files, err := discovery.DiscoverSkillFiles(opts.Client, opts.Host, opts.Owner, opts.Repo, skill.TreeSHA, skill.Path)
if err != nil {
return fmt.Errorf("could not list skill files: %w", err)
}
safeSkillDir, err := safepaths.ParseAbsolute(skillDir)
if err != nil {
return fmt.Errorf("could not resolve skill directory path: %w", err)
}
for _, file := range files {
content, err := discovery.FetchBlob(opts.Client, opts.Host, opts.Owner, opts.Repo, file.SHA)
if err != nil {
return fmt.Errorf("could not fetch %s: %w", file.Path, err)
}
relPath := strings.TrimPrefix(file.Path, skill.Path+"/")
safeDest, err := safeSkillDir.Join(relPath)
if err != nil {
var traversalErr safepaths.PathTraversalError
if errors.As(err, &traversalErr) {
return fmt.Errorf("blocked path traversal in %q", relPath)
}
return fmt.Errorf("could not resolve destination path: %w", err)
}
destPath := safeDest.String()
if dir := filepath.Dir(destPath); dir != skillDir {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("could not create directory: %w", err)
}
}
if filepath.Base(relPath) == "SKILL.md" {
content, err = frontmatter.InjectGitHubMetadata(content, opts.Host, opts.Owner, opts.Repo, opts.Ref, skill.TreeSHA, opts.PinnedRef, skill.Path)
if err != nil {
return fmt.Errorf("could not inject metadata: %w", err)
}
}
if err := os.WriteFile(destPath, []byte(content), 0o644); err != nil {
return fmt.Errorf("could not write %s: %w", destPath, err)
}
}
return nil
}
// ResolveGitRoot returns the git repository root using the provided client,
// falling back to the current working directory on error.
func ResolveGitRoot(gc *git.Client) string {
if gc != nil && gc.RepoDir != "" {
return gc.RepoDir
}
if gc != nil {
if root, err := gc.ToplevelDir(context.Background()); err == nil {
return root
}
}
if cwd, err := os.Getwd(); err == nil {
return cwd
}
return ""
}
// ResolveHomeDir returns the user's home directory, or "" on error.
func ResolveHomeDir() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return home
}

View file

@ -0,0 +1,518 @@
package installer
import (
"encoding/base64"
"fmt"
"net/http"
"os"
"path/filepath"
"sync/atomic"
"testing"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/skills/discovery"
"github.com/cli/cli/v2/internal/skills/registry"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestInstallLocal(t *testing.T) {
tests := []struct {
name string
skills []discovery.Skill
useAgentHost bool
setup func(t *testing.T, srcDir string)
verify func(t *testing.T, destDir string)
wantErr string
}{
{
name: "copies files via Dir",
skills: []discovery.Skill{{Name: "code-review", Path: "skills/code-review"}},
setup: func(t *testing.T, srcDir string) {
t.Helper()
skillSrc := filepath.Join(srcDir, "skills", "code-review")
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",
skills: []discovery.Skill{{Name: "issue-triage", Path: "skills/issue-triage"}},
setup: func(t *testing.T, srcDir string) {
t.Helper()
deep := filepath.Join(srcDir, "skills", "issue-triage", "prompts", "templates")
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",
skills: []discovery.Skill{{Name: "pr-summary", Path: "skills/pr-summary"}},
setup: func(t *testing.T, srcDir string) {
t.Helper()
skillSrc := filepath.Join(srcDir, "skills", "pr-summary")
require.NoError(t, os.MkdirAll(skillSrc, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("# PR Summary"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(skillSrc, "prompt.txt"), []byte("summarize"), 0o644))
require.NoError(t, 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",
skills: []discovery.Skill{{Name: "copilot-helper", Path: "skills/copilot-helper"}},
setup: func(t *testing.T, srcDir string) {
t.Helper()
skillSrc := filepath.Join(srcDir, "skills", "copilot-helper")
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.Contains(t, string(content), "local-path")
},
},
{
name: "multiple skills",
skills: []discovery.Skill{
{Name: "code-review", Path: "skills/code-review"},
{Name: "issue-triage", Path: "skills/issue-triage"},
},
setup: func(t *testing.T, srcDir string) {
t.Helper()
for _, name := range []string{"code-review", "issue-triage"} {
skillSrc := filepath.Join(srcDir, "skills", name)
require.NoError(t, os.MkdirAll(skillSrc, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("# "+name), 0o644))
}
},
verify: func(t *testing.T, destDir string) {
t.Helper()
_, err := os.Stat(filepath.Join(destDir, "code-review", "SKILL.md"))
assert.NoError(t, err)
_, err = os.Stat(filepath.Join(destDir, "issue-triage", "SKILL.md"))
assert.NoError(t, err)
},
},
{
name: "resolves install dir from AgentHost and Scope",
skills: []discovery.Skill{{Name: "code-review", Path: "skills/code-review"}},
useAgentHost: true,
setup: func(t *testing.T, srcDir string) {
t.Helper()
skillSrc := filepath.Join(srcDir, "skills", "code-review")
require.NoError(t, os.MkdirAll(skillSrc, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("# Code Review"), 0o644))
},
verify: func(t *testing.T, destDir string) {
t.Helper()
_, err := os.Stat(filepath.Join(destDir, ".agents", "skills", "code-review", "SKILL.md"))
assert.NoError(t, err)
},
},
{
name: "no dir or agent host",
skills: []discovery.Skill{{Name: "code-review"}},
setup: func(t *testing.T, srcDir string) {},
wantErr: "either Dir or AgentHost must be specified",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
srcDir := t.TempDir()
destDir := t.TempDir()
tt.setup(t, srcDir)
opts := &LocalOptions{
SourceDir: srcDir,
Skills: tt.skills,
Dir: destDir,
}
if tt.useAgentHost {
host, err := registry.FindByID("github-copilot")
require.NoError(t, err)
opts.Dir = ""
opts.AgentHost = host
opts.Scope = registry.ScopeProject
opts.GitRoot = destDir
}
if tt.wantErr != "" {
opts.Dir = ""
}
result, err := InstallLocal(opts)
if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
return
}
require.NoError(t, err)
assert.NotEmpty(t, result.Dir)
assert.Len(t, result.Installed, len(tt.skills))
tt.verify(t, destDir)
})
}
}
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.NotContains(t, string(content), "github-owner:")
assert.Contains(t, string(content), "github-repo: https://github.com/monalisa/octocat-skills")
},
},
{
name: "fails on path traversal from malicious tree",
skill: discovery.Skill{Name: "code-review", Path: "skills/code-review", TreeSHA: "tree123"},
stubs: func(reg *httpmock.Registry) {
reg.Register(
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, "..", "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)
if tt.name == "fails on path traversal from malicious tree" {
require.Error(t, err)
assert.Contains(t, err.Error(), "blocked path traversal")
} else {
require.NoError(t, err)
}
tt.verify(t, destDir)
})
}
}
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) {
var progressCount atomic.Int32
tests := []struct {
name string
skills []discovery.Skill
stubs func(*httpmock.Registry)
onProgress func(done, total int)
wantInstalled []string
wantErr string
}{
{
name: "single skill calls OnProgress",
skills: []discovery.Skill{
{Name: "code-review", Path: "skills/code-review", TreeSHA: "tree-cr"},
},
stubs: func(reg *httpmock.Registry) { stubTreeAndBlob(reg, "tree-cr") },
onProgress: func(done, total int) {
progressCount.Add(1)
},
wantInstalled: []string{"code-review"},
},
{
name: "multiple skills concurrently with progress",
skills: []discovery.Skill{
{Name: "code-review", Path: "skills/code-review", TreeSHA: "tree-cr"},
{Name: "issue-triage", Path: "skills/issue-triage", TreeSHA: "tree-it"},
},
stubs: func(reg *httpmock.Registry) {
stubTreeAndBlob(reg, "tree-cr")
stubTreeAndBlob(reg, "tree-it")
},
onProgress: func(done, total int) {
progressCount.Add(1)
},
wantInstalled: []string{"code-review", "issue-triage"},
},
{
name: "partial failure returns successful installs and error",
skills: []discovery.Skill{
{Name: "code-review", Path: "skills/code-review", TreeSHA: "tree-cr"},
{Name: "issue-triage", Path: "skills/issue-triage", TreeSHA: "tree-fail"},
},
stubs: func(reg *httpmock.Registry) {
stubTreeAndBlob(reg, "tree-cr")
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-fail"),
httpmock.StatusStringResponse(500, "server error"))
},
wantInstalled: []string{"code-review"},
wantErr: "failed to install skill",
},
{
name: "no dir or agent host",
skills: []discovery.Skill{{Name: "code-review"}},
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) {
progressCount.Store(0)
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
t.Setenv("USERPROFILE", homeDir)
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,
OnProgress: tt.onProgress,
}
if tt.wantErr != "" && len(tt.wantInstalled) == 0 {
opts.Dir = ""
}
result, err := Install(opts)
if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
if len(tt.wantInstalled) > 0 {
require.NotNil(t, result, "partial failure should return non-nil result")
assert.ElementsMatch(t, tt.wantInstalled, result.Installed)
}
return
}
require.NoError(t, err)
assert.ElementsMatch(t, tt.wantInstalled, result.Installed)
assert.Equal(t, destDir, result.Dir)
homeDir, _ = os.UserHomeDir()
lockPath := filepath.Join(homeDir, ".agents", ".skill-lock.json")
lockData, err := os.ReadFile(lockPath)
require.NoError(t, err, "lockfile should have been written")
for _, name := range tt.wantInstalled {
assert.Contains(t, string(lockData), name)
}
if tt.onProgress != nil {
assert.True(t, progressCount.Load() > 0, "OnProgress should have been called")
}
})
}
}
func TestInstallSingleSkillFailureStillCompletesProgress(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
t.Setenv("USERPROFILE", homeDir)
destDir := t.TempDir()
reg := &httpmock.Registry{}
defer reg.Verify(t)
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-fail"),
httpmock.StatusStringResponse(500, "server error"),
)
client := api.NewClientFromHTTP(&http.Client{Transport: reg})
var events []struct{ done, total int }
result, err := Install(&Options{
Host: "github.com",
Owner: "monalisa",
Repo: "octocat-skills",
Ref: "v1.0",
SHA: "commit123",
Client: client,
Skills: []discovery.Skill{
{Name: "code-review", Path: "skills/code-review", TreeSHA: "tree-fail"},
},
Dir: destDir,
OnProgress: func(done, total int) {
events = append(events, struct{ done, total int }{done: done, total: total})
},
})
require.Error(t, err)
assert.Nil(t, result)
assert.Equal(t, []struct{ done, total int }{{done: 0, total: 1}, {done: 1, total: 1}}, events)
}
func TestResolveGitRoot(t *testing.T) {
tests := []struct {
name string
client *git.Client
wantDir string
}{
{
name: "returns RepoDir when set",
client: &git.Client{RepoDir: "/monalisa/repo"},
wantDir: "/monalisa/repo",
},
{
name: "nil client falls back to cwd",
client: nil,
},
{
name: "empty RepoDir falls back to ToplevelDir or cwd",
client: &git.Client{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ResolveGitRoot(tt.client)
if tt.wantDir != "" {
assert.Equal(t, tt.wantDir, got)
} else {
assert.NotEmpty(t, got, "should fall back to ToplevelDir or cwd")
}
})
}
}

View file

@ -0,0 +1,177 @@
package lockfile
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"time"
"github.com/cli/cli/v2/internal/flock"
)
const (
// lockVersion must match Vercel's CURRENT_LOCK_VERSION for interop.
lockVersion = 3
agentsDir = ".agents"
lockFile = ".skill-lock.json"
)
// entry represents a single installed skill in the lock file.
type entry struct {
Source string `json:"source"`
SourceType string `json:"sourceType"`
SourceURL string `json:"sourceUrl"`
SkillPath string `json:"skillPath,omitempty"`
SkillFolderHash string `json:"skillFolderHash"`
InstalledAt string `json:"installedAt"`
UpdatedAt string `json:"updatedAt"`
PinnedRef string `json:"pinnedRef,omitempty"`
}
// file is the top-level structure of .skill-lock.json.
type file struct {
Version int `json:"version"`
Skills map[string]entry `json:"skills"`
Dismissed map[string]bool `json:"dismissed,omitempty"`
}
// lockfilePath returns the absolute path to the lock file.
func lockfilePath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, agentsDir, lockFile), nil
}
// readFrom loads the lock file from an open file handle.
// Returns an empty file if the content is empty, corrupt, or incompatible.
func readFrom(f *os.File) (*file, error) {
if _, err := f.Seek(0, 0); err != nil {
return nil, fmt.Errorf("could not seek lock file: %w", err)
}
data, err := io.ReadAll(f)
if err != nil {
return nil, fmt.Errorf("could not read lock file: %w", err)
}
if len(data) == 0 {
return newFile(), nil
}
var lf file
if err := json.Unmarshal(data, &lf); err != nil {
return newFile(), nil //nolint:nilerr // graceful: corrupt file means fresh state
}
if lf.Version != lockVersion || lf.Skills == nil {
return newFile(), nil
}
return &lf, nil
}
// writeTo persists the lock file through an open file handle.
func writeTo(f *os.File, lf *file) error {
data, err := json.MarshalIndent(lf, "", " ")
if err != nil {
return err
}
if _, err := f.Seek(0, 0); err != nil {
return err
}
if err := f.Truncate(0); err != nil {
return err
}
_, err = f.Write(data)
return err
}
// RecordInstall adds or updates a skill entry in the lock file.
// It uses a file-based lock to prevent concurrent read-modify-write races
// when multiple install processes run simultaneously.
func RecordInstall(skillName, owner, repo, skillPath, treeSHA, pinnedRef string) error {
lockPath, err := lockfilePath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil {
return fmt.Errorf("could not create lock directory: %w", err)
}
lockedFile, unlock, err := acquireFLock()
if err != nil {
return err
}
defer unlock()
f, err := readFrom(lockedFile)
if err != nil {
return err
}
now := time.Now().UTC().Format(time.RFC3339)
existing, exists := f.Skills[skillName]
installedAt := now
if exists {
installedAt = existing.InstalledAt
}
f.Skills[skillName] = entry{
Source: owner + "/" + repo,
SourceType: "github",
SourceURL: "https://github.com/" + owner + "/" + repo + ".git",
SkillPath: skillPath,
SkillFolderHash: treeSHA,
InstalledAt: installedAt,
UpdatedAt: now,
PinnedRef: pinnedRef,
}
return writeTo(lockedFile, f)
}
func newFile() *file {
return &file{
Version: lockVersion,
Skills: make(map[string]entry),
}
}
var (
lockAttempts = 30
lockAttemptDelay = 100 * time.Millisecond
)
// acquireFLock attempts to acquire an exclusive file lock to serialize concurrent access.
// Returns the locked file handle and an unlock function, or an error if the lock
// cannot be acquired. The caller should read/write through the returned file to
// avoid Windows mandatory lock conflicts.
func acquireFLock() (f *os.File, unlock func(), err error) {
lockPath, err := lockfilePath()
if err != nil {
return nil, nil, fmt.Errorf("could not determine lock path: %w", err)
}
var lastErr error
for attempt := range lockAttempts {
f, unlock, err := flock.TryLock(lockPath)
if err == nil {
return f, unlock, nil
}
lastErr = err
if !errors.Is(err, flock.ErrLocked) {
return nil, nil, err
}
if attempt < lockAttempts-1 {
time.Sleep(lockAttemptDelay)
}
}
return nil, nil, fmt.Errorf("could not acquire lock after %d attempts: %w", lockAttempts, lastErr)
}

View file

@ -0,0 +1,203 @@
package lockfile
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/cli/cli/v2/internal/flock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// setupTestHome redirects HOME to a temp dir and returns the expected lockfile path.
func setupTestHome(t *testing.T) string {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
return filepath.Join(home, agentsDir, lockFile)
}
func TestRecordInstall(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T)
skill string
owner string
repo string
skillPath string
treeSHA string
pinnedRef string
wantErr bool
verify func(t *testing.T, lockPath string)
}{
{
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 := readTestLockfile(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 := readTestLockfile(t, lockPath)
assert.Equal(t, "v1.0.0", f.Skills["pr-summary"].PinnedRef)
},
},
{
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 := readTestLockfile(t, lockPath)
assert.Contains(t, f.Skills, "code-review")
assert.Contains(t, f.Skills, "issue-triage")
},
},
{
name: "returns error when lock cannot be acquired",
setup: func(t *testing.T) {
t.Helper()
origAttempts := lockAttempts
origDelay := lockAttemptDelay
lockAttempts = 1
lockAttemptDelay = 0
t.Cleanup(func() {
lockAttempts = origAttempts
lockAttemptDelay = origDelay
})
// Hold a real flock so acquireFLock fails.
lockPath, err := lockfilePath()
require.NoError(t, err)
require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755))
_, unlock, err := flock.TryLock(lockPath)
require.NoError(t, err)
t.Cleanup(unlock)
},
skill: "code-review",
owner: "monalisa",
repo: "octocat-skills",
skillPath: "skills/code-review/SKILL.md",
treeSHA: "abc123",
wantErr: true,
},
{
name: "recovers from corrupt lockfile",
setup: func(t *testing.T) {
t.Helper()
lockPath, err := lockfilePath()
require.NoError(t, err)
require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755))
require.NoError(t, os.WriteFile(lockPath, []byte("{invalid json"), 0o644))
},
skill: "code-review",
owner: "monalisa",
repo: "octocat-skills",
skillPath: "skills/code-review/SKILL.md",
treeSHA: "abc123",
verify: func(t *testing.T, lockPath string) {
t.Helper()
f := readTestLockfile(t, lockPath)
assert.Equal(t, lockVersion, f.Version)
require.Contains(t, f.Skills, "code-review")
},
},
{
name: "recovers from wrong version lockfile",
setup: func(t *testing.T) {
t.Helper()
lockPath, err := lockfilePath()
require.NoError(t, err)
require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755))
data, _ := json.Marshal(file{Version: 999, Skills: map[string]entry{"old-skill": {}}})
require.NoError(t, os.WriteFile(lockPath, data, 0o644))
},
skill: "code-review",
owner: "monalisa",
repo: "octocat-skills",
skillPath: "skills/code-review/SKILL.md",
treeSHA: "abc123",
verify: func(t *testing.T, lockPath string) {
t.Helper()
f := readTestLockfile(t, lockPath)
assert.Equal(t, lockVersion, f.Version)
require.Contains(t, f.Skills, "code-review")
assert.NotContains(t, f.Skills, "old-skill", "wrong-version data should be discarded")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
lockPath := setupTestHome(t)
if tt.setup != nil {
tt.setup(t)
}
err := RecordInstall(tt.skill, tt.owner, tt.repo, tt.skillPath, tt.treeSHA, tt.pinnedRef)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
tt.verify(t, lockPath)
})
}
// This case lives outside the table because it needs to read the lockfile
// between two RecordInstall calls to capture the first InstalledAt value.
t.Run("update preserves InstalledAt and updates treeSHA", func(t *testing.T) {
lockPath := setupTestHome(t)
require.NoError(t, RecordInstall("code-review", "monalisa", "octocat-skills", "skills/code-review/SKILL.md", "old-sha", ""))
firstInstalledAt := readTestLockfile(t, lockPath).Skills["code-review"].InstalledAt
require.NoError(t, RecordInstall("code-review", "monalisa", "octocat-skills", "skills/code-review/SKILL.md", "new-sha", ""))
entry := readTestLockfile(t, lockPath).Skills["code-review"]
assert.Equal(t, "new-sha", entry.SkillFolderHash, "treeSHA should be updated")
assert.Equal(t, firstInstalledAt, entry.InstalledAt, "InstalledAt should be preserved from first install")
})
}
// readTestLockfile is a test helper that reads and parses the lockfile from disk.
func readTestLockfile(t *testing.T, path string) *file {
t.Helper()
data, err := os.ReadFile(path)
require.NoError(t, err, "lockfile should exist at %s", path)
var f file
require.NoError(t, json.Unmarshal(data, &f))
return &f
}

View file

@ -0,0 +1,173 @@
package registry
import (
"fmt"
"path/filepath"
"strings"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/ghrepo"
)
// 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
// ProjectDir is the relative path within a project for skills.
ProjectDir string
// UserDir is the relative path within the user's home directory for skills.
UserDir string
}
// Scope determines where skills are installed.
type Scope string
const (
ScopeProject Scope = "project"
ScopeUser Scope = "user"
DefaultAgentID = "github-copilot"
sharedProjectSkillsDir = ".agents/skills"
)
// Agents contains all known agent hosts.
var Agents = []AgentHost{
{
ID: "github-copilot",
Name: "GitHub Copilot",
ProjectDir: sharedProjectSkillsDir,
UserDir: ".copilot/skills",
},
{
ID: "claude-code",
Name: "Claude Code",
ProjectDir: ".claude/skills",
UserDir: ".claude/skills",
},
{
ID: "cursor",
Name: "Cursor",
ProjectDir: sharedProjectSkillsDir,
UserDir: ".cursor/skills",
},
{
ID: "codex",
Name: "Codex",
ProjectDir: sharedProjectSkillsDir,
UserDir: ".codex/skills",
},
{
ID: "gemini",
Name: "Gemini CLI",
ProjectDir: sharedProjectSkillsDir,
UserDir: ".gemini/skills",
},
{
ID: "antigravity",
Name: "Antigravity",
ProjectDir: sharedProjectSkillsDir,
UserDir: ".gemini/antigravity/skills",
},
}
// 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 agent %q, valid agents: %s", id, ValidAgentIDs())
}
// ValidAgentIDs returns a comma-separated list of valid agent IDs.
func ValidAgentIDs() string {
return strings.Join(AgentIDs(), ", ")
}
// 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
}
// 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 Agents list, preserving insertion order.
func UniqueProjectDirs() []string {
seen := map[string]bool{}
var dirs []string
for _, h := range Agents {
if !seen[h.ProjectDir] {
seen[h.ProjectDir] = true
dirs = append(dirs, h.ProjectDir)
}
}
return dirs
}
// 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 *AgentHost) InstallDir(scope Scope, gitRoot, homeDir string) (string, error) {
switch scope {
case ScopeProject:
if gitRoot == "" {
return "", fmt.Errorf("could not determine project root directory")
}
return filepath.Join(gitRoot, h.ProjectDir), nil
case ScopeUser:
if homeDir == "" {
return "", fmt.Errorf("could not determine home directory")
}
return filepath.Join(homeDir, h.UserDir), nil
default:
return "", fmt.Errorf("invalid scope %q", scope)
}
}
// ScopeLabels returns the display labels for the scope selection prompt.
// If repoName is non-empty, it is included in the project-scope label
// for additional context.
func ScopeLabels(repoName string) []string {
projectLabel := "Project: install in current repository (recommended)"
if repoName != "" {
projectLabel = fmt.Sprintf("Project: %s (recommended)", repoName)
}
return []string{
projectLabel,
"Global: install in home directory (available everywhere)",
}
}
// RepoNameFromRemote extracts "owner/repo" from a git remote URL.
func RepoNameFromRemote(remote string) string {
if remote == "" {
return ""
}
u, err := git.ParseURL(remote)
if err != nil {
return ""
}
repo, err := ghrepo.FromURL(u)
if err != nil {
return ""
}
return ghrepo.FullName(repo)
}

View file

@ -0,0 +1,205 @@
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) {
tests := []struct {
name string
hostID string
scope Scope
gitRoot string
homeDir string
wantDir string
wantErr bool
}{
{
name: "github copilot project scope",
hostID: "github-copilot",
scope: ScopeProject,
gitRoot: "/tmp/monalisa-repo",
homeDir: "/home/monalisa",
wantDir: filepath.Join("/tmp/monalisa-repo", ".agents", "skills"),
},
{
name: "github copilot user scope",
hostID: "github-copilot",
scope: ScopeUser,
gitRoot: "/tmp/monalisa-repo",
homeDir: "/home/monalisa",
wantDir: filepath.Join("/home/monalisa", ".copilot", "skills"),
},
{
name: "claude code project scope",
hostID: "claude-code",
scope: ScopeProject,
gitRoot: "/tmp/monalisa-repo",
homeDir: "/home/monalisa",
wantDir: filepath.Join("/tmp/monalisa-repo", ".claude", "skills"),
},
{
name: "cursor project scope",
hostID: "cursor",
scope: ScopeProject,
gitRoot: "/tmp/monalisa-repo",
homeDir: "/home/monalisa",
wantDir: filepath.Join("/tmp/monalisa-repo", ".agents", "skills"),
},
{
name: "codex project scope",
hostID: "codex",
scope: ScopeProject,
gitRoot: "/tmp/monalisa-repo",
homeDir: "/home/monalisa",
wantDir: filepath.Join("/tmp/monalisa-repo", ".agents", "skills"),
},
{
name: "gemini project scope",
hostID: "gemini",
scope: ScopeProject,
gitRoot: "/tmp/monalisa-repo",
homeDir: "/home/monalisa",
wantDir: filepath.Join("/tmp/monalisa-repo", ".agents", "skills"),
},
{
name: "antigravity project scope",
hostID: "antigravity",
scope: ScopeProject,
gitRoot: "/tmp/monalisa-repo",
homeDir: "/home/monalisa",
wantDir: filepath.Join("/tmp/monalisa-repo", ".agents", "skills"),
},
{
name: "project scope without git root",
hostID: "github-copilot",
scope: ScopeProject,
gitRoot: "",
homeDir: "/home/monalisa",
wantErr: true,
},
{
name: "user scope without home dir",
hostID: "github-copilot",
scope: ScopeUser,
gitRoot: "/tmp/monalisa-repo",
homeDir: "",
wantErr: true,
},
{
name: "invalid scope",
hostID: "github-copilot",
scope: "bogus",
gitRoot: "/tmp/monalisa-repo",
homeDir: "/home/monalisa",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
host, err := FindByID(tt.hostID)
require.NoError(t, err)
dir, err := host.InstallDir(tt.scope, tt.gitRoot, tt.homeDir)
if tt.wantErr {
assert.Error(t, err)
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"},
{"not-a-url", ""},
{"", ""},
}
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()
assert.Equal(t, []string{".agents/skills", ".claude/skills"}, dirs)
}
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

@ -0,0 +1,66 @@
package source
import (
"fmt"
"strings"
"github.com/cli/cli/v2/internal/ghrepo"
)
const SupportedHost = "github.com"
// BuildRepoURL returns the canonical repository URL stored in skill metadata.
func BuildRepoURL(host, owner, repo string) string {
return ghrepo.GenerateRepoURL(ghrepo.NewWithHost(owner, repo, host), "")
}
// ParseRepoURL parses a repository URL stored in skill metadata.
func ParseRepoURL(raw string) (ghrepo.Interface, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, fmt.Errorf("repository URL is empty")
}
repo, err := ghrepo.FromFullName(raw)
if err != nil {
return nil, fmt.Errorf("invalid repository URL %q: %w", raw, err)
}
return repo, nil
}
// ParseMetadataRepo extracts repository information from skill metadata.
func ParseMetadataRepo(meta map[string]interface{}) (ghrepo.Interface, bool, error) {
if meta == nil {
return nil, false, nil
}
repoValue, _ := meta["github-repo"].(string)
if repoValue == "" {
return nil, false, nil
}
repo, err := ParseRepoURL(repoValue)
if err != nil {
return nil, true, err
}
return repo, true, nil
}
// ValidateSupportedHost rejects hosts that are not supported in public preview.
func ValidateSupportedHost(host string) error {
host = normalizeHost(host)
if host == "" {
return fmt.Errorf("could not determine repository host")
}
if host != SupportedHost {
return fmt.Errorf("GitHub Skills currently supports only %s as a host; got %s", SupportedHost, host)
}
return nil
}
func normalizeHost(host string) string {
host = strings.TrimSpace(strings.ToLower(host))
return strings.TrimPrefix(host, "www.")
}

View file

@ -0,0 +1,76 @@
package source
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBuildRepoURL(t *testing.T) {
assert.Equal(t, "https://github.com/monalisa/octocat-skills", BuildRepoURL("github.com", "monalisa", "octocat-skills"))
}
func TestParseMetadataRepo(t *testing.T) {
tests := []struct {
name string
meta map[string]interface{}
wantOwner string
wantRepo string
wantHost string
wantFound bool
wantErr string
}{
{
name: "parses repo url metadata",
meta: map[string]interface{}{
"github-repo": "https://github.com/monalisa/octocat-skills",
},
wantOwner: "monalisa",
wantRepo: "octocat-skills",
wantHost: SupportedHost,
wantFound: true,
},
{
name: "invalid repo url",
meta: map[string]interface{}{
"github-repo": "not a url",
},
wantFound: true,
wantErr: "invalid repository URL",
},
{
name: "missing repo metadata",
meta: map[string]interface{}{},
wantFound: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo, found, err := ParseMetadataRepo(tt.meta)
assert.Equal(t, tt.wantFound, found)
if !tt.wantFound {
require.NoError(t, err)
assert.Nil(t, repo)
return
}
if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
return
}
require.NoError(t, err)
require.NotNil(t, repo)
assert.Equal(t, tt.wantOwner, repo.RepoOwner())
assert.Equal(t, tt.wantRepo, repo.RepoName())
assert.Equal(t, tt.wantHost, repo.RepoHost())
})
}
}
func TestValidateSupportedHost(t *testing.T) {
require.NoError(t, ValidateSupportedHost("github.com"))
require.ErrorContains(t, ValidateSupportedHost("acme.ghes.com"), "supports only github.com")
}

View file

@ -38,6 +38,7 @@ import (
runCmd "github.com/cli/cli/v2/pkg/cmd/run"
searchCmd "github.com/cli/cli/v2/pkg/cmd/search"
secretCmd "github.com/cli/cli/v2/pkg/cmd/secret"
skillsCmd "github.com/cli/cli/v2/pkg/cmd/skills"
sshKeyCmd "github.com/cli/cli/v2/pkg/cmd/ssh-key"
statusCmd "github.com/cli/cli/v2/pkg/cmd/status"
variableCmd "github.com/cli/cli/v2/pkg/cmd/variable"
@ -144,6 +145,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command,
cmd.AddCommand(codespaceCmd.NewCmdCodespace(f))
cmd.AddCommand(projectCmd.NewCmdProject(f))
cmd.AddCommand(previewCmd.NewCmdPreview(f))
cmd.AddCommand(skillsCmd.NewCmdSkills(f))
// Root commands with standalone functionality and no subcommands
cmd.AddCommand(copilotCmd.NewCmdCopilot(f, nil))

View file

@ -0,0 +1,994 @@
package install
import (
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"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/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/internal/skills/source"
"github.com/cli/cli/v2/internal/text"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/spf13/cobra"
)
const (
// allSkillsKey is the persistent option label for selecting all skills.
allSkillsKey = "(all skills)"
// maxSearchResults caps how many skills are shown per search page in
// interactive selection, keeping the prompt readable.
maxSearchResults = 30
)
// InstallOptions holds all dependencies and user-provided flags for the install command.
type InstallOptions struct {
IO *iostreams.IOStreams
HttpClient func() (*http.Client, error)
Prompter prompter.Prompter
GitClient *git.Client
Remotes func() (ghContext.Remotes, error)
SkillSource string // owner/repo or local path (when --from-local is set)
SkillName string // possibly with @version suffix
Agent string
Scope string
ScopeChanged bool // true when --scope was explicitly set
Pin string
Dir string // overrides --agent and --scope
Force bool
FromLocal bool // treat SkillSource as a local directory path
repo ghrepo.Interface // set when SkillSource is a GitHub repository
localPath string // set when FromLocal is true
version string // parsed from SkillName@version
}
// NewCmdInstall creates the "skills install" command.
func NewCmdInstall(f *cmdutil.Factory, runF func(*InstallOptions) error) *cobra.Command {
opts := &InstallOptions{
IO: f.IOStreams,
Prompter: f.Prompter,
GitClient: f.GitClient,
Remotes: f.Remotes,
HttpClient: f.HttpClient,
}
cmd := &cobra.Command{
Use: "install <repository> [<skill[@version]>] [flags]",
Short: "Install agent skills from a GitHub repository (preview)",
Long: heredoc.Docf(`
Install agent skills from a GitHub repository or local directory into
your local environment. Skills are placed in a host-specific directory
at either project scope (inside the current git repository) or user
scope (in your home directory, available everywhere). Supported hosts
and their storage directories are (project, user):
- GitHub Copilot (%[1]s.agents/skills%[1]s, %[1]s~/.copilot/skills%[1]s)
- Claude Code (%[1]s.claude/skills%[1]s, %[1]s~/.claude/skills%[1]s)
- Cursor (%[1]s.agents/skills%[1]s, %[1]s~/.cursor/skills%[1]s)
- Codex (%[1]s.agents/skills%[1]s, %[1]s~/.codex/skills%[1]s)
- Gemini CLI (%[1]s.agents/skills%[1]s, %[1]s~/.gemini/skills%[1]s)
- Antigravity (%[1]s.agents/skills%[1]s, %[1]s~/.gemini/antigravity/skills%[1]s)
Use %[1]s--agent%[1]s and %[1]s--scope%[1]s to control placement, or %[1]s--dir%[1]s for a
custom directory. The default scope is %[1]sproject%[1]s, and the default
agent is %[1]sgithub-copilot%[1]s (when running non-interactively).
At project scope, GitHub Copilot, Cursor, Codex, Gemini CLI, and
Antigravity all use the shared %[1]s.agents/skills%[1]s directory. If you
select multiple hosts that resolve to the same destination, each skill is
installed there only once.
The first argument is a GitHub repository in %[1]sOWNER/REPO%[1]s format.
Use %[1]s--from-local%[1]s to install from a local directory instead.
Local skills are auto-discovered using the same conventions as remote
repositories, and files are copied (not symlinked) with local-path
tracking metadata injected into frontmatter.
Skills are discovered automatically using the %[1]sskills/*/SKILL.md%[1]s convention
defined by the Agent Skills specification. For more information on the specification,
see: https://agentskills.io/specification
The skill argument can be a name, a namespaced name (%[1]sauthor/skill%[1]s),
or an exact path within the repository (%[1]sskills/author/skill%[1]s or
%[1]sskills/author/skill/SKILL.md%[1]s).
Performance tip: when installing from a large repository with many
skills, providing an exact path instead of a skill name avoids a
full tree traversal of the repository, making the install significantly faster.
When a skill name is provided without a version, the CLI resolves the
version in this order:
1. Latest tagged release in the repository
2. Default branch HEAD
To pin to a specific version, either append %[1]s@VERSION%[1]s to the skill
name or use the %[1]s--pin%[1]s flag. The version is resolved as a git tag or commit SHA.
Installed skills have source tracking metadata injected into their
frontmatter. This metadata identifies the source repository and
enables %[1]sgh skill update%[1]s to detect changes.
When run interactively, the command prompts for any missing arguments.
When run non-interactively, %[1]srepository%[1]s and a skill name are
required.
`, "`"),
Example: heredoc.Doc(`
# Interactive: choose repo, skill, and agent
$ gh skill install
# Choose a skill from the repo interactively
$ gh skill install github/awesome-copilot
# Install a specific skill
$ gh skill install github/awesome-copilot git-commit
# Install a specific version
$ gh skill install github/awesome-copilot git-commit@v1.2.0
# Install from a large namespaced repo by path (efficient, skips full discovery)
$ gh skill install github/awesome-copilot skills/monalisa/code-review
# Install from a local directory
$ gh skill install ./my-skills-repo --from-local
# Install a specific local skill
$ gh skill install ./my-skills-repo git-commit --from-local
# Install for Claude Code at user scope
$ gh skill install github/awesome-copilot git-commit --agent claude-code --scope user
# Pin to a specific git ref
$ gh skill install github/awesome-copilot git-commit --pin v2.0.0
`),
Aliases: []string{"add"},
Args: cobra.MaximumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) >= 1 {
opts.SkillSource = args[0]
}
if len(args) >= 2 {
opts.SkillName = args[1]
}
opts.ScopeChanged = cmd.Flags().Changed("scope")
// Resolve the source type early so installRun can branch directly.
if opts.FromLocal {
if opts.SkillSource == "" {
return cmdutil.FlagErrorf("--from-local requires a directory path argument")
}
opts.localPath = opts.SkillSource
} else if len(args) == 0 && !opts.IO.CanPrompt() {
return cmdutil.FlagErrorf("must specify a repository to install from")
}
if err := cmdutil.MutuallyExclusive("--from-local and --pin cannot be used together", opts.FromLocal, opts.Pin != ""); err != nil {
return err
}
if opts.Pin != "" && opts.SkillName != "" && strings.Contains(opts.SkillName, "@") {
return cmdutil.FlagErrorf("cannot use --pin with an inline @version in the skill name")
}
if runF != nil {
return runF(opts)
}
return installRun(opts)
},
}
cmdutil.StringEnumFlag(cmd, &opts.Agent, "agent", "", "", registry.AgentIDs(), "Target agent")
cmdutil.StringEnumFlag(cmd, &opts.Scope, "scope", "", "project", []string{"project", "user"}, "Installation scope")
cmd.Flags().StringVar(&opts.Pin, "pin", "", "Pin to a specific git tag or commit SHA")
cmd.Flags().StringVar(&opts.Dir, "dir", "", "Install to a custom directory (overrides --agent and --scope)")
cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Overwrite existing skills without prompting")
cmd.Flags().BoolVar(&opts.FromLocal, "from-local", false, "Treat the argument as a local directory path instead of a repository")
cmdutil.DisableAuthCheckFlag(cmd.Flags().Lookup("from-local"))
return cmd
}
func installRun(opts *InstallOptions) error {
cs := opts.IO.ColorScheme()
canPrompt := opts.IO.CanPrompt()
if opts.localPath != "" {
return runLocalInstall(opts)
}
repo, repoSource, err := resolveRepoArg(opts.SkillSource, canPrompt, opts.Prompter)
if err != nil {
return err
}
opts.repo = repo
opts.SkillSource = repoSource
parseSkillFromOpts(opts)
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
hostname := opts.repo.RepoHost()
if err := source.ValidateSupportedHost(hostname); err != nil {
return err
}
resolved, err := resolveVersion(opts, apiClient, hostname)
if err != nil {
return err
}
var selectedSkills []discovery.Skill
if isSkillPath(opts.SkillName) {
opts.IO.StartProgressIndicatorWithLabel("Looking up skill")
skill, err := discovery.DiscoverSkillByPath(apiClient, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), resolved.SHA, opts.SkillName)
opts.IO.StopProgressIndicator()
if err != nil {
return err
}
selectedSkills = []discovery.Skill{*skill}
} else {
skills, err := discoverSkills(opts, apiClient, hostname, resolved)
if err != nil {
return err
}
selectedSkills, err = selectSkillsWithSelector(opts, skills, canPrompt, skillSelector{
matchByName: matchSkillByName,
sourceHint: ghrepo.FullName(opts.repo),
fetchDescriptions: func() {
opts.IO.StartProgressIndicatorWithLabel("Fetching skill info")
discovery.FetchDescriptionsConcurrent(apiClient, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), skills, nil)
opts.IO.StopProgressIndicator()
},
})
if err != nil {
return err
}
}
printPreInstallDisclaimer(opts.IO.ErrOut, cs)
selectedHosts, err := resolveHosts(opts, canPrompt)
if err != nil {
return err
}
scope, err := resolveScope(opts, canPrompt)
if err != nil {
return err
}
gitRoot := installer.ResolveGitRoot(opts.GitClient)
homeDir := installer.ResolveHomeDir()
repoSource = ghrepo.FullName(opts.repo)
plans, err := buildInstallPlans(opts, selectedSkills, selectedHosts, scope, gitRoot, homeDir, canPrompt)
if err != nil {
return err
}
for _, plan := range plans {
if len(plans) > 1 {
fmt.Fprintf(opts.IO.ErrOut, "\nInstalling to %s for %s...\n", friendlyDir(plan.dir), formatPlanHosts(plan.hosts))
}
result, err := installer.Install(&installer.Options{
Host: hostname,
Owner: opts.repo.RepoOwner(),
Repo: opts.repo.RepoName(),
Ref: resolved.Ref,
SHA: resolved.SHA,
PinnedRef: opts.Pin,
Skills: plan.skills,
Dir: plan.dir,
Client: apiClient,
OnProgress: installProgress(opts.IO, len(plan.skills)),
})
if result != nil {
for _, w := range result.Warnings {
fmt.Fprintf(opts.IO.ErrOut, "%s %s\n", cs.WarningIcon(), w)
}
for _, name := range result.Installed {
fmt.Fprintf(opts.IO.Out, "%s Installed %s (from %s@%s) in %s\n",
cs.SuccessIcon(), name, repoSource, discovery.ShortRef(resolved.Ref), friendlyDir(result.Dir))
}
printFileTree(opts.IO.ErrOut, cs, result.Dir, result.Installed)
printReviewHint(opts.IO.ErrOut, cs, repoSource, resolved.SHA, result.Installed)
}
if err != nil {
return err
}
}
return nil
}
// runLocalInstall handles installation from a local directory path.
func runLocalInstall(opts *InstallOptions) error {
cs := opts.IO.ColorScheme()
canPrompt := opts.IO.CanPrompt()
sourcePath := opts.localPath
if sourcePath == "~" {
if home, err := os.UserHomeDir(); err == nil {
sourcePath = home
}
} else if after, ok := strings.CutPrefix(sourcePath, "~/"); ok {
if home, err := os.UserHomeDir(); err == nil {
sourcePath = filepath.Join(home, after)
}
}
absSource, err := filepath.Abs(sourcePath)
if err != nil {
return fmt.Errorf("could not resolve path: %w", err)
}
opts.IO.StartProgressIndicatorWithLabel("Discovering skills")
skills, err := discovery.DiscoverLocalSkills(absSource)
opts.IO.StopProgressIndicator()
if err != nil {
return err
}
if canPrompt {
fmt.Fprintf(opts.IO.ErrOut, "Found %d skill(s)\n", len(skills))
}
selectedSkills, err := selectSkillsWithSelector(opts, skills, canPrompt, skillSelector{
matchByName: matchLocalSkillByName,
sourceHint: absSource,
})
if err != nil {
return err
}
printPreInstallDisclaimer(opts.IO.ErrOut, cs)
selectedHosts, err := resolveHosts(opts, canPrompt)
if err != nil {
return err
}
scope, err := resolveScope(opts, canPrompt)
if err != nil {
return err
}
gitRoot := installer.ResolveGitRoot(opts.GitClient)
homeDir := installer.ResolveHomeDir()
plans, err := buildInstallPlans(opts, selectedSkills, selectedHosts, scope, gitRoot, homeDir, canPrompt)
if err != nil {
return err
}
for _, plan := range plans {
if len(plans) > 1 {
fmt.Fprintf(opts.IO.ErrOut, "\nInstalling to %s for %s...\n", friendlyDir(plan.dir), formatPlanHosts(plan.hosts))
}
result, err := installer.InstallLocal(&installer.LocalOptions{
SourceDir: absSource,
Skills: plan.skills,
Dir: plan.dir,
})
if err != nil {
return err
}
for _, name := range result.Installed {
fmt.Fprintf(opts.IO.Out, "Installed %s (from %s) in %s\n",
name, opts.SkillSource, friendlyDir(result.Dir))
}
printFileTree(opts.IO.ErrOut, cs, result.Dir, result.Installed)
printReviewHint(opts.IO.ErrOut, cs, "", "", result.Installed)
}
return nil
}
// isSkillPath returns true if the argument looks like a repo-relative path
// rather than a simple skill name.
func isSkillPath(name string) bool {
if name == "" {
return false
}
if name == "SKILL.md" || strings.HasSuffix(name, "/SKILL.md") {
return true
}
if strings.HasPrefix(name, "skills/") || strings.HasPrefix(name, "plugins/") {
return true
}
return false
}
func resolveRepoArg(skillSource string, canPrompt bool, p prompter.Prompter) (ghrepo.Interface, string, error) {
if skillSource == "" {
if !canPrompt {
return nil, "", cmdutil.FlagErrorf("must specify a repository to install from")
}
repoInput, err := p.Input("Repository (owner/repo):", "")
if err != nil {
return nil, "", err
}
skillSource = strings.TrimSpace(repoInput)
if skillSource == "" {
return nil, "", fmt.Errorf("must specify a repository to install from")
}
}
repo, err := ghrepo.FromFullName(skillSource)
if err != nil {
return nil, "", cmdutil.FlagErrorf("invalid repository reference %q: expected OWNER/REPO, HOST/OWNER/REPO, or a full URL", skillSource)
}
return repo, skillSource, nil
}
func parseSkillFromOpts(opts *InstallOptions) {
if opts.SkillName != "" {
if name, version, ok := cutLast(opts.SkillName, "@"); ok && name != "" {
opts.version = version
opts.SkillName = name
return
}
}
if opts.Pin != "" {
opts.version = opts.Pin
}
}
// cutLast splits s around the last occurrence of sep,
// returning the text before and after sep, and whether sep was found.
func cutLast(s, sep string) (before, after string, found bool) {
if i := strings.LastIndex(s, sep); i >= 0 {
return s[:i], s[i+len(sep):], true
}
return s, "", false
}
func resolveVersion(opts *InstallOptions, client *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", discovery.ShortRef(resolved.Ref), git.ShortSHA(resolved.SHA))
return resolved, nil
}
func discoverSkills(opts *InstallOptions, client *api.Client, hostname string, resolved *discovery.ResolvedRef) ([]discovery.Skill, error) {
opts.IO.StartProgressIndicatorWithLabel("Discovering skills")
skills, err := discovery.DiscoverSkills(client, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), resolved.SHA)
opts.IO.StopProgressIndicator()
if err != nil {
var treeTooLarge *discovery.TreeTooLargeError
if errors.As(err, &treeTooLarge) {
fmt.Fprintf(opts.IO.ErrOut, "%s\n Use path-based install instead: gh skill install %s/%s skills/<skill-name>\n",
err, treeTooLarge.Owner, treeTooLarge.Repo)
return nil, err
}
return nil, err
}
logConventions(opts.IO, skills)
for _, s := range skills {
if !discovery.IsSpecCompliant(s.Name) {
fmt.Fprintf(opts.IO.ErrOut, "Warning: skill %q does not follow the agentskills.io naming convention\n", s.DisplayName())
}
}
return skills, nil
}
func logConventions(io *iostreams.IOStreams, skills []discovery.Skill) {
conventions := make(map[string]int)
for _, s := range skills {
conventions[s.Convention]++
}
if n, ok := conventions["skills-namespaced"]; ok {
fmt.Fprintf(io.ErrOut, "Note: found %d namespaced skill(s) in skills/{author}/ directories\n", n)
}
if n, ok := conventions["plugins"]; ok {
fmt.Fprintf(io.ErrOut, "Note: found %d skill(s) using the plugins/ convention\n", n)
}
if n, ok := conventions["root"]; ok {
fmt.Fprintf(io.ErrOut, "Note: found %d skill(s) at the repository root\n", n)
}
}
// skillSelector holds the callbacks that differ between remote and local skill selection.
type skillSelector struct {
// matchByName resolves a skill name to matching skills.
matchByName func(opts *InstallOptions, skills []discovery.Skill) ([]discovery.Skill, error)
// sourceHint is shown in collision error guidance (e.g. "owner/repo" or "/path/to/skills").
sourceHint string
// fetchDescriptions, if non-nil, is called before prompting to pre-populate descriptions.
fetchDescriptions func()
}
type installPlan struct {
dir string
hosts []*registry.AgentHost
skills []discovery.Skill
}
func selectSkillsWithSelector(opts *InstallOptions, skills []discovery.Skill, canPrompt bool, sel skillSelector) ([]discovery.Skill, error) {
checkCollisions := func(ss []discovery.Skill) error {
if err := collisionError(ss); err != nil {
fmt.Fprintf(opts.IO.ErrOut, "Hint: install individually using the full name: gh skill install %s namespace/skill-name\n", sel.sourceHint)
return err
}
return nil
}
if opts.SkillName != "" {
return sel.matchByName(opts, skills)
}
if !canPrompt {
return nil, cmdutil.FlagErrorf("must specify a skill name when not running interactively")
}
if sel.fetchDescriptions != nil {
sel.fetchDescriptions()
}
tw := opts.IO.TerminalWidth()
descWidth := tw - 35
if descWidth < 20 {
descWidth = 20
}
selected, err := opts.Prompter.MultiSelectWithSearch(
"Select skill(s) to install:",
"Filter skills",
nil,
[]string{allSkillsKey},
skillSearchFunc(skills, descWidth),
)
if err != nil {
return nil, err
}
if len(selected) == 0 {
return nil, fmt.Errorf("must select at least one skill")
}
for _, s := range selected {
if s == allSkillsKey {
if err := checkCollisions(skills); err != nil {
return nil, err
}
return skills, nil
}
}
result, err := matchSelectedSkills(skills, selected)
if err != nil {
return nil, err
}
return result, checkCollisions(result)
}
func matchSkillByName(opts *InstallOptions, skills []discovery.Skill) ([]discovery.Skill, error) {
for _, s := range skills {
if s.DisplayName() == opts.SkillName {
return []discovery.Skill{s}, nil
}
}
var matches []discovery.Skill
for _, s := range skills {
if s.Name == opts.SkillName {
matches = append(matches, s)
}
}
switch len(matches) {
case 0:
return nil, fmt.Errorf("skill %q not found in %s", opts.SkillName, ghrepo.FullName(opts.repo))
case 1:
return matches, nil
default:
names := make([]string, len(matches))
for i, m := range matches {
names[i] = m.DisplayName()
}
return nil, fmt.Errorf(
"skill name %q is ambiguous, multiple matches found:\n %s\n Specify the full name (e.g. %s) to disambiguate",
opts.SkillName, strings.Join(names, "\n "), names[0],
)
}
}
func matchLocalSkillByName(opts *InstallOptions, skills []discovery.Skill) ([]discovery.Skill, error) {
for _, s := range skills {
if s.DisplayName() == opts.SkillName || s.Name == opts.SkillName {
return []discovery.Skill{s}, nil
}
}
return nil, fmt.Errorf("skill %q not found in local directory", opts.SkillName)
}
// skillSearchFunc returns a search function for MultiSelectWithSearch that
// filters skills by case-insensitive substring match on name and description.
func skillSearchFunc(skills []discovery.Skill, descWidth int) func(string) prompter.MultiSelectSearchResult {
return func(query string) prompter.MultiSelectSearchResult {
var matched []discovery.Skill
if query == "" {
matched = skills
} else {
q := strings.ToLower(query)
for _, s := range skills {
if strings.Contains(strings.ToLower(s.DisplayName()), q) ||
strings.Contains(strings.ToLower(s.Description), q) {
matched = append(matched, s)
}
}
}
more := 0
if len(matched) > maxSearchResults {
more = len(matched) - maxSearchResults
matched = matched[:maxSearchResults]
}
keys := make([]string, len(matched))
labels := make([]string, len(matched))
for i, s := range matched {
keys[i] = s.DisplayName()
if s.Description != "" {
labels[i] = fmt.Sprintf("%s - %s", s.DisplayName(), truncateDescription(s.Description, descWidth))
} else {
labels[i] = s.DisplayName()
}
}
return prompter.MultiSelectSearchResult{
Keys: keys,
Labels: labels,
MoreResults: more,
}
}
}
// matchSelectedSkills maps display names back to skill structs.
func matchSelectedSkills(skills []discovery.Skill, selected []string) ([]discovery.Skill, error) {
nameSet := make(map[string]struct{}, len(selected))
for _, name := range selected {
nameSet[name] = struct{}{}
}
var result []discovery.Skill
for _, s := range skills {
if _, ok := nameSet[s.DisplayName()]; ok {
result = append(result, s)
}
}
if len(result) == 0 {
return nil, fmt.Errorf("no matching skills found")
}
return result, nil
}
// collisionError checks for name collisions among the selected skills.
func collisionError(ss []discovery.Skill) error {
collisions := discovery.FindNameCollisions(ss)
if len(collisions) == 0 {
return nil
}
return fmt.Errorf("cannot install skills with conflicting names; they would overwrite each other:\n %s",
discovery.FormatCollisions(collisions))
}
func resolveHosts(opts *InstallOptions, canPrompt bool) ([]*registry.AgentHost, error) {
if opts.Agent != "" {
h, err := registry.FindByID(opts.Agent)
if err != nil {
return nil, err
}
return []*registry.AgentHost{h}, nil
}
if !canPrompt {
h, err := registry.FindByID(registry.DefaultAgentID)
if err != nil {
return nil, err
}
return []*registry.AgentHost{h}, nil
}
fmt.Fprintln(opts.IO.ErrOut)
names := registry.AgentNames()
indices, err := opts.Prompter.MultiSelect("Select target agent(s):", []string{names[0]}, names)
if err != nil {
return nil, err
}
if len(indices) == 0 {
return nil, fmt.Errorf("must select at least one target agent")
}
selected := make([]*registry.AgentHost, len(indices))
for i, idx := range indices {
selected[i] = &registry.Agents[idx]
}
return selected, nil
}
func resolveScope(opts *InstallOptions, canPrompt bool) (registry.Scope, error) {
if opts.Dir != "" {
return registry.Scope(opts.Scope), nil
}
if opts.ScopeChanged || !canPrompt {
return registry.Scope(opts.Scope), nil
}
var repoName string
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:", "", registry.ScopeLabels(repoName))
if err != nil {
return "", err
}
if idx == 0 {
return registry.ScopeProject, nil
}
return registry.ScopeUser, nil
}
func buildInstallPlans(opts *InstallOptions, selectedSkills []discovery.Skill, selectedHosts []*registry.AgentHost, scope registry.Scope, gitRoot, homeDir string, canPrompt bool) ([]installPlan, error) {
byDir := make(map[string]*installPlan)
orderedDirs := make([]string, 0, len(selectedHosts))
for _, host := range selectedHosts {
targetDir, err := resolveInstallDir(opts, host, scope, gitRoot, homeDir)
if err != nil {
return nil, err
}
plan, ok := byDir[targetDir]
if !ok {
plan = &installPlan{dir: targetDir}
byDir[targetDir] = plan
orderedDirs = append(orderedDirs, targetDir)
}
plan.hosts = append(plan.hosts, host)
}
plans := make([]installPlan, 0, len(orderedDirs))
for _, dir := range orderedDirs {
plan := byDir[dir]
installSkills, err := checkOverwrite(opts, selectedSkills, plan.dir, canPrompt)
if err != nil {
return nil, err
}
if len(installSkills) == 0 {
fmt.Fprintf(opts.IO.ErrOut, "No skills to install in %s for %s.\n", friendlyDir(plan.dir), formatPlanHosts(plan.hosts))
continue
}
plan.skills = installSkills
plans = append(plans, *plan)
}
return plans, nil
}
func resolveInstallDir(opts *InstallOptions, host *registry.AgentHost, scope registry.Scope, gitRoot, homeDir string) (string, error) {
if opts.Dir != "" {
return opts.Dir, nil
}
return host.InstallDir(scope, gitRoot, homeDir)
}
func formatPlanHosts(hosts []*registry.AgentHost) string {
names := make([]string, len(hosts))
for i, host := range hosts {
names[i] = host.Name
}
return strings.Join(names, ", ")
}
func truncateDescription(s string, maxWidth int) string {
return text.Truncate(maxWidth, text.RemoveExcessiveWhitespace(s))
}
func checkOverwrite(opts *InstallOptions, skills []discovery.Skill, targetDir string, canPrompt bool) ([]discovery.Skill, error) {
var existing, fresh []discovery.Skill
for _, s := range skills {
dir := filepath.Join(targetDir, filepath.FromSlash(s.InstallName()))
if _, err := os.Stat(dir); err == nil {
existing = append(existing, s)
} else {
fresh = append(fresh, s)
}
}
if len(existing) == 0 {
return skills, nil
}
if opts.Force {
return skills, nil
}
if !canPrompt {
names := make([]string, len(existing))
for i, s := range existing {
names[i] = s.DisplayName()
}
return nil, fmt.Errorf("skills already installed: %s (use --force to overwrite)", strings.Join(names, ", "))
}
var confirmed []discovery.Skill
for _, s := range existing {
prompt := existingSkillPrompt(targetDir, s)
ok, err := opts.Prompter.Confirm(prompt, false)
if err != nil {
return nil, err
}
if ok {
confirmed = append(confirmed, s)
} else {
fmt.Fprintf(opts.IO.ErrOut, "Skipping %s\n", s.DisplayName())
}
}
return append(fresh, confirmed...), nil
}
func existingSkillPrompt(targetDir string, incoming discovery.Skill) string {
skillFile := filepath.Join(targetDir, filepath.FromSlash(incoming.InstallName()), "SKILL.md")
data, err := os.ReadFile(skillFile)
if err != nil {
return fmt.Sprintf("Skill %q already exists. Overwrite?", incoming.DisplayName())
}
result, err := frontmatter.Parse(string(data))
if err != nil || result.Metadata.Meta == nil {
return fmt.Sprintf("Skill %q already exists. Overwrite?", incoming.DisplayName())
}
repoInfo, _, err := source.ParseMetadataRepo(result.Metadata.Meta)
ref, _ := result.Metadata.Meta["github-ref"].(string)
if err != nil {
return fmt.Sprintf("Skill %q already exists. Overwrite?", incoming.DisplayName())
}
if repoInfo != nil {
sourceName := ghrepo.FullName(repoInfo)
if ref != "" {
sourceName += "@" + ref
}
return fmt.Sprintf("Skill %q already installed from %s. Overwrite?", incoming.DisplayName(), sourceName)
}
return fmt.Sprintf("Skill %q already exists. Overwrite?", incoming.DisplayName())
}
const installProgressLabel = "Downloading skill files"
func installProgress(io *iostreams.IOStreams, total int) func(done, total int) {
if total <= 0 {
return nil
}
return func(done, total int) {
if done == 0 {
io.StartProgressIndicatorWithLabel(installProgressLabel)
} else if done >= total {
io.StopProgressIndicator()
}
}
}
func friendlyDir(dir string) string {
if cwd, err := os.Getwd(); err == nil {
if rel, err := filepath.Rel(cwd, dir); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
if rel == "." {
return filepath.Base(dir)
}
return rel
}
}
if home, err := os.UserHomeDir(); err == nil {
if rel, err := filepath.Rel(home, dir); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "~/" + rel
}
}
return dir
}
// printFileTree renders a text tree of the on-disk contents of each skill directory.
func printFileTree(w io.Writer, cs *iostreams.ColorScheme, dir string, skillNames []string) {
if len(skillNames) == 0 {
return
}
fmt.Fprintln(w)
for _, name := range skillNames {
skillDir := filepath.Join(dir, filepath.FromSlash(name))
fmt.Fprintf(w, " %s\n", cs.Bold(name+"/"))
printTreeDir(w, cs, skillDir, " ")
}
}
func printTreeDir(w io.Writer, cs *iostreams.ColorScheme, dir, indent string) {
entries, err := os.ReadDir(dir)
if err != nil {
fmt.Fprintf(w, "%s%s\n", indent, cs.Muted("(could not read directory)"))
return
}
for i, entry := range entries {
isLast := i == len(entries)-1
connector := "├── "
childIndent := "│ "
if isLast {
connector = "└── "
childIndent = " "
}
name := entry.Name()
if entry.IsDir() {
fmt.Fprintf(w, "%s%s%s\n", indent, cs.Muted(connector), cs.Bold(name+"/"))
printTreeDir(w, cs, filepath.Join(dir, name), indent+cs.Muted(childIndent))
} else {
fmt.Fprintf(w, "%s%s%s\n", indent, cs.Muted(connector), name)
}
}
}
// printPreInstallDisclaimer prints a warning that installed skills are unverified
// and should be inspected before use.
func printPreInstallDisclaimer(w io.Writer, cs *iostreams.ColorScheme) {
fmt.Fprintf(w, "\n%s Skills are not verified by GitHub and may contain prompt injections, hidden instructions, or malicious scripts. Always review skill contents before use.\n\n", cs.WarningIcon())
}
// printReviewHint warns the user to review installed skills and suggests preview commands.
// When sha is non-empty the suggested commands include @SHA so the user previews
// exactly the version that was installed.
func printReviewHint(w io.Writer, cs *iostreams.ColorScheme, repo, sha string, skillNames []string) {
if len(skillNames) == 0 {
return
}
fmt.Fprintf(w, "\n%s Skills may contain prompt injections or malicious scripts.\n", cs.WarningIcon())
if repo == "" {
fmt.Fprintln(w, " Review the installed files before use.")
return
}
fmt.Fprintln(w, " Review installed content before use:")
fmt.Fprintln(w)
for _, name := range skillNames {
if sha != "" {
fmt.Fprintf(w, " gh skill preview %s %s@%s\n", repo, name, sha)
} else {
fmt.Fprintf(w, " gh skill preview %s %s\n", repo, name)
}
}
fmt.Fprintln(w)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,439 @@
package preview
import (
"fmt"
"io"
"net/http"
"path"
"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/internal/skills/source"
"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
RenderFile func(string, string) string
RepoArg string
SkillName string
Version string // resolved from @suffix on SkillName
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,
}
opts.RenderFile = func(filePath, content string) string {
return renderMarkdownPreview(opts.IO, filePath, content)
}
cmd := &cobra.Command{
Use: "preview <repository> [<skill>]",
Short: "Preview a skill from a GitHub repository (preview)",
Long: heredoc.Docf(`
Render a skill's %[1]sSKILL.md%[1]s content in the terminal. This fetches the
skill file from the repository and displays it using the configured
pager, without installing anything.
A file tree is shown first, followed by the rendered %[1]sSKILL.md%[1]s content.
When running interactively and the skill contains additional files
(scripts, references, etc.), a file picker lets you browse them
individually.
When run with only a repository argument, lists available skills and
prompts for selection.
To preview a specific version of the skill, append %[1]s@VERSION%[1]s to the
skill name. The version is resolved as a git tag, branch, or commit SHA.
`, "`"),
Example: heredoc.Doc(`
# Preview a specific skill
$ gh skill preview github/awesome-copilot documentation-writer
# Preview a skill at a specific version
$ gh skill preview github/awesome-copilot documentation-writer@v1.2.0
# Preview a skill at a specific commit SHA
$ gh skill preview github/awesome-copilot documentation-writer@abc123def456
# Browse and preview interactively
$ gh skill 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]
}
if i := strings.LastIndex(opts.SkillName, "@"); i > 0 {
opts.Version = opts.SkillName[i+1:]
opts.SkillName = opts.SkillName[:i]
}
repo, err := ghrepo.FromFullName(opts.RepoArg)
if err != nil {
return err
}
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()
if err := source.ValidateSupportedHost(hostname); err != nil {
return err
}
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.Version)
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
}
rendered := opts.renderFile("SKILL.md", 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.Muted(fmt.Sprintf("(skipped remaining files, showing first %d)", maxFiles)))
break
}
if totalBytes+f.Size > maxTotalBytes {
fmt.Fprintf(out, "\n%s\n", cs.Muted("(skipped remaining files, size limit reached)"))
break
}
fileContent, fetchErr := discovery.FetchBlob(apiClient, hostname, owner, repo, f.SHA)
if fetchErr != nil {
fmt.Fprintf(out, "\n%s\n\n%s\n", cs.Bold("── "+f.Path+" ──"), cs.Muted("(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 = renderSelectedFilePreview(opts, selectedFile.Path, 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 (opts *PreviewOptions) renderFile(filePath, content string) string {
if opts.RenderFile != nil {
return opts.RenderFile(filePath, content)
}
return renderMarkdownPreview(opts.IO, filePath, content)
}
func renderSelectedFilePreview(opts *PreviewOptions, filePath, content string) string {
if !isMarkdownFile(filePath) {
return content
}
return opts.renderFile(filePath, content)
}
func renderMarkdownPreview(io *iostreams.IOStreams, filePath, content string) string {
if filePath == "SKILL.md" {
parsed, err := frontmatter.Parse(content)
if err == nil {
content = parsed.Body
}
}
rendered, err := markdown.Render(content,
markdown.WithTheme(io.TerminalTheme()),
markdown.WithWrap(io.TerminalWidth()),
markdown.WithoutIndentation())
if err != nil {
return content
}
return rendered
}
func isMarkdownFile(filePath string) bool {
switch strings.ToLower(path.Ext(filePath)) {
case ".md", ".markdown", ".mdown", ".mkd", ".mkdn":
return true
default:
return false
}
}
func selectSkill(opts *PreviewOptions, skills []discovery.Skill) (discovery.Skill, error) {
if opts.SkillName != "" {
for _, s := range skills {
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.Muted(connector), cs.Bold(node.name+"/"))
printTree(w, cs, node.children, indent+cs.Muted(childIndent))
} else {
fmt.Fprintf(w, "%s%s%s\n", indent, cs.Muted(connector), node.name)
}
}
}

View file

@ -0,0 +1,804 @@
package preview
import (
"encoding/base64"
"fmt"
"io"
"net/http"
"strings"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/prompter"
"github.com/cli/cli/v2/pkg/cmdutil"
"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 TestNewCmdPreview(t *testing.T) {
tests := []struct {
name string
input string
wantRepo string
wantSkillName string
wantVersion string
wantErr bool
}{
{
name: "repo and skill",
input: "github/awesome-copilot my-skill",
wantRepo: "github/awesome-copilot",
wantSkillName: "my-skill",
},
{
name: "repo and skill with version",
input: "github/awesome-copilot my-skill@v1.2.0",
wantRepo: "github/awesome-copilot",
wantSkillName: "my-skill",
wantVersion: "v1.2.0",
},
{
name: "repo and skill with SHA",
input: "github/awesome-copilot my-skill@abc123def456",
wantRepo: "github/awesome-copilot",
wantSkillName: "my-skill",
wantVersion: "abc123def456",
},
{
name: "repo only",
input: "github/awesome-copilot",
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(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantRepo, gotOpts.RepoArg)
assert.Equal(t, tt.wantSkillName, gotOpts.SkillName)
assert.Equal(t, tt.wantVersion, gotOpts.Version)
})
}
}
func TestPreviewRun(t *testing.T) {
skillContent := heredoc.Doc(`
---
name: my-skill
description: A test skill
---
# My Skill
This is the skill content.
`)
encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent))
tests := []struct {
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",
},
{
name: "preview with explicit version",
tty: true,
opts: &PreviewOptions{
repo: ghrepo.New("github", "awesome-copilot"),
SkillName: "my-skill",
Version: "abc123def456",
},
httpStubs: func(reg *httpmock.Registry) {
// ResolveRef with explicit version tries branch first, then tag, then commit
reg.Register(
httpmock.REST("GET", "repos/github/awesome-copilot/git/ref/heads/abc123def456"),
httpmock.StatusStringResponse(404, "not found"),
)
reg.Register(
httpmock.REST("GET", "repos/github/awesome-copilot/git/ref/tags/abc123def456"),
httpmock.StatusStringResponse(404, "not found"),
)
reg.Register(
httpmock.REST("GET", "repos/github/awesome-copilot/commits/abc123def456"),
httpmock.StringResponse(`{"sha": "abc123def456789012345678901234567890abcd"}`),
)
reg.Register(
httpmock.REST("GET", "repos/github/awesome-copilot/git/trees/abc123def456789012345678901234567890abcd"),
httpmock.StringResponse(`{
"sha": "abc123def456789012345678901234567890abcd",
"truncated": false,
"tree": [
{"path": "skills", "type": "tree", "sha": "tree1"},
{"path": "skills/my-skill", "type": "tree", "sha": "treeSHA"},
{"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blob123"}
]
}`),
)
reg.Register(
httpmock.REST("GET", "repos/github/awesome-copilot/git/trees/treeSHA"),
httpmock.StringResponse(`{
"tree": [
{"path": "SKILL.md", "type": "blob", "sha": "blob123", "size": 50}
]
}`),
)
reg.Register(
httpmock.REST("GET", "repos/github/awesome-copilot/git/blobs/blob123"),
httpmock.StringResponse(`{"sha": "blob123", "content": "`+encodedContent+`", "encoding": "base64"}`),
)
},
wantStdout: "My Skill",
},
}
for _, tt := range tests {
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 != "" {
require.EqualError(t, err, tt.wantErr)
return
}
require.NoError(t, err)
if tt.wantStdout != "" {
assert.Contains(t, stdout.String(), tt.wantStdout)
}
})
}
}
func TestPreviewRun_UnsupportedHost(t *testing.T) {
ios, _, _, _ := iostreams.Test()
err := previewRun(&PreviewOptions{
IO: ios,
HttpClient: func() (*http.Client, error) { return &http.Client{}, nil },
repo: ghrepo.NewWithHost("github", "awesome-copilot", "acme.ghes.com"),
})
require.ErrorContains(t, err, "supports only github.com")
}
func TestPreviewRun_Interactive(t *testing.T) {
skillContent := "# Selected Skill\n\nContent here."
encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent))
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)
require.NoError(t, err)
assert.Contains(t, stdout.String(), "Selected Skill")
}
func TestPreviewRun_ShowsFileTree(t *testing.T) {
skillContent := heredoc.Doc(`
---
name: my-skill
description: test
---
# My Skill
Body.
`)
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)
require.NoError(t, err)
out := stdout.String()
assert.Contains(t, out, "echo hello")
assert.Equal(t, 2, selectCalls)
})
t.Run("interactive markdown file uses markdown renderer", func(t *testing.T) {
readmeContent := "# Usage\n\nUse **carefully**."
encodedReadme := base64.StdEncoding.EncodeToString([]byte(readmeContent))
reg := &httpmock.Registry{}
defer reg.Verify(t)
reg.Register(
httpmock.REST("GET", "repos/owner/repo/releases/latest"),
httpmock.StringResponse(`{"tag_name": "v1.0.0"}`),
)
reg.Register(
httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"),
httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`),
)
reg.Register(
httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"),
httpmock.StringResponse(`{
"sha": "abc123",
"truncated": false,
"tree": [
{"path": "skills/my-skill", "type": "tree", "sha": "treeSHA"},
{"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobSKILL"},
{"path": "skills/my-skill/README.md", "type": "blob", "sha": "blobREADME"}
]
}`),
)
reg.Register(
httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA"),
httpmock.StringResponse(`{
"tree": [
{"path": "SKILL.md", "type": "blob", "sha": "blobSKILL", "size": 50},
{"path": "README.md", "type": "blob", "sha": "blobREADME", "size": 28}
]
}`),
)
reg.Register(
httpmock.REST("GET", "repos/owner/repo/git/blobs/blobSKILL"),
httpmock.StringResponse(`{"sha": "blobSKILL", "content": "`+encodedContent+`", "encoding": "base64"}`),
)
reg.Register(
httpmock.REST("GET", "repos/owner/repo/git/blobs/blobREADME"),
httpmock.StringResponse(`{"sha": "blobREADME", "content": "`+encodedReadme+`", "encoding": "base64"}`),
)
ios, _, stdout, _ := iostreams.Test()
ios.SetStdoutTTY(true)
ios.SetStdinTTY(true)
ios.SetColorEnabled(false)
renderCalls := 0
selectCalls := 0
pm := &prompter.PrompterMock{
SelectFunc: func(prompt string, defaultValue string, options []string) (int, error) {
selectCalls++
if selectCalls == 1 {
assert.Equal(t, []string{"SKILL.md", "README.md"}, options)
return 1, nil
}
return 0, fmt.Errorf("user cancelled")
},
}
opts := &PreviewOptions{
IO: ios,
HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil },
Prompter: pm,
repo: ghrepo.New("owner", "repo"),
SkillName: "my-skill",
RenderFile: func(filePath, content string) string {
renderCalls++
return fmt.Sprintf("rendered:%s", filePath)
},
}
err := previewRun(opts)
require.NoError(t, err)
out := stdout.String()
assert.Contains(t, out, "rendered:README.md")
assert.Equal(t, 2, selectCalls)
assert.Equal(t, 2, renderCalls)
})
t.Run("non-interactive dumps all files", func(t *testing.T) {
reg := makeReg()
defer reg.Verify(t)
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)
require.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")
})
}
func TestPreviewRun_RenderLimits(t *testing.T) {
skillContent := heredoc.Doc(`
---
name: my-skill
description: test
---
# My Skill
`)
encodedSkill := base64.StdEncoding.EncodeToString([]byte(skillContent))
// Helper: build a tree JSON with N extra files (beyond SKILL.md)
buildTree := func(n int) string {
entries := []string{
`{"path": "skills/my-skill", "type": "tree", "sha": "treeSHA"}`,
`{"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobSKILL"}`,
}
for i := range n {
entries = append(entries, fmt.Sprintf(
`{"path": "skills/my-skill/file%03d.txt", "type": "blob", "sha": "blob%03d"}`, i, i))
}
return fmt.Sprintf(`{"sha":"abc123","truncated":false,"tree":[%s]}`,
strings.Join(entries, ","))
}
// Helper: build subtree JSON with N extra files
buildSubtree := func(n int, sizes []int) string {
entries := []string{
`{"path": "SKILL.md", "type": "blob", "sha": "blobSKILL", "size": 50}`,
}
for i := range n {
sz := 10
if i < len(sizes) {
sz = sizes[i]
}
entries = append(entries, fmt.Sprintf(
`{"path": "file%03d.txt", "type": "blob", "sha": "blob%03d", "size": %d}`, i, i, sz))
}
return fmt.Sprintf(`{"tree":[%s]}`, strings.Join(entries, ","))
}
// Common stubs for resolve + discover
registerBase := func(reg *httpmock.Registry, treeJSON, subtreeJSON string) {
reg.Register(
httpmock.REST("GET", "repos/monalisa/skills-repo/releases/latest"),
httpmock.StringResponse(`{"tag_name": "v1.0.0"}`),
)
reg.Register(
httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/tags/v1.0.0"),
httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`),
)
reg.Register(
httpmock.REST("GET", "repos/monalisa/skills-repo/git/trees/abc123"),
httpmock.StringResponse(treeJSON),
)
reg.Register(
httpmock.REST("GET", "repos/monalisa/skills-repo/git/trees/treeSHA"),
httpmock.StringResponse(subtreeJSON),
)
reg.Register(
httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blobSKILL"),
httpmock.StringResponse(`{"sha": "blobSKILL", "content": "`+encodedSkill+`", "encoding": "base64"}`),
)
}
t.Run("maxFiles cap truncates at 20", func(t *testing.T) {
reg := &httpmock.Registry{}
defer reg.Verify(t)
n := 22
treeJSON := buildTree(n)
subtreeJSON := buildSubtree(n, nil)
registerBase(reg, treeJSON, subtreeJSON)
// Register blob stubs for files 0-19 (first 20 get fetched)
tinyContent := base64.StdEncoding.EncodeToString([]byte("tiny"))
for i := range 20 {
reg.Register(
httpmock.REST("GET", fmt.Sprintf("repos/monalisa/skills-repo/git/blobs/blob%03d", i)),
httpmock.StringResponse(fmt.Sprintf(`{"sha": "blob%03d", "content": "%s", "encoding": "base64"}`, i, tinyContent)),
)
}
// Files 20 and 21 should NOT be fetched
ios, _, stdout, _ := iostreams.Test()
ios.SetStdoutTTY(false)
ios.SetStdinTTY(false)
opts := &PreviewOptions{
IO: ios,
HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil },
Prompter: &prompter.PrompterMock{},
repo: ghrepo.New("monalisa", "skills-repo"),
SkillName: "my-skill",
}
err := previewRun(opts)
require.NoError(t, err)
out := stdout.String()
assert.Contains(t, out, "showing first 20")
assert.Contains(t, out, "file019.txt") // last fetched
})
t.Run("maxBytes cap stops fetching", func(t *testing.T) {
reg := &httpmock.Registry{}
defer reg.Verify(t)
// Two files: first is 500KB, second would exceed 512KB cap
sizes := []int{500 * 1024, 100 * 1024}
treeJSON := buildTree(2)
subtreeJSON := buildSubtree(2, sizes)
registerBase(reg, treeJSON, subtreeJSON)
bigContent := base64.StdEncoding.EncodeToString(make([]byte, 500*1024))
reg.Register(
httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blob000"),
httpmock.StringResponse(fmt.Sprintf(`{"sha": "blob000", "content": "%s", "encoding": "base64"}`, bigContent)),
)
// blob001 should NOT be fetched (size limit reached)
ios, _, stdout, _ := iostreams.Test()
ios.SetStdoutTTY(false)
ios.SetStdinTTY(false)
opts := &PreviewOptions{
IO: ios,
HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil },
Prompter: &prompter.PrompterMock{},
repo: ghrepo.New("monalisa", "skills-repo"),
SkillName: "my-skill",
}
err := previewRun(opts)
require.NoError(t, err)
out := stdout.String()
assert.Contains(t, out, "size limit reached")
})
t.Run("blob fetch error shows fallback message", func(t *testing.T) {
reg := &httpmock.Registry{}
defer reg.Verify(t)
treeJSON := buildTree(1)
subtreeJSON := buildSubtree(1, nil)
registerBase(reg, treeJSON, subtreeJSON)
reg.Register(
httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blob000"),
httpmock.StatusStringResponse(500, "server error"),
)
ios, _, stdout, _ := iostreams.Test()
ios.SetStdoutTTY(false)
ios.SetStdinTTY(false)
opts := &PreviewOptions{
IO: ios,
HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil },
Prompter: &prompter.PrompterMock{},
repo: ghrepo.New("monalisa", "skills-repo"),
SkillName: "my-skill",
}
err := previewRun(opts)
require.NoError(t, err)
out := stdout.String()
assert.Contains(t, out, "could not fetch file")
})
}

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,915 @@
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/skills/source"
"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",
"namespace",
"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> [flags]",
Short: "Search for skills across GitHub (preview)",
Long: heredoc.Docf(`
Search across all public GitHub repositories for skills matching a keyword.
Uses the GitHub Code Search API to find %[1]sSKILL.md%[1]s files whose name or
description matches the query term.
Results are ranked by relevance: skills whose name contains the query
term appear first.
Use %[1]s--owner%[1]s to scope results to a specific GitHub user or organization.
In interactive mode, you can select skills from the results to install directly.
`, "`"),
Example: heredoc.Doc(`
# Search for skills related to terraform
$ gh skill search terraform
# Search for skills from a specific owner
$ gh skill search terraform --owner hashicorp
# View the second page of results
$ gh skill search terraform --page 2
# Limit results to 5
$ gh skill 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
Namespace string // namespace prefix: author/scope for skills/{author}/* or plugin name for plugins/{plugin}/skills/*
Description string
Path string // original file path (e.g. skills/terraform/SKILL.md)
BlobSHA string
Stars int // repository stargazer count
}
// qualifiedName returns the namespace-qualified skill name (e.g. "author/skill")
// or just the skill name if there is no namespace.
func (s skillResult) qualifiedName() string {
if s.Namespace != "" {
return s.Namespace + "/" + s.SkillName
}
return s.SkillName
}
// ExportData implements cmdutil.exportable for --json output.
func (s skillResult) ExportData(fields []string) map[string]interface{} {
data := map[string]interface{}{}
for _, f := range fields {
switch f {
case "repo":
data[f] = s.Repo
case "skillName":
data[f] = s.SkillName
case "namespace":
data[f] = s.Namespace
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()
if err := source.ValidateSupportedHost(host); err != nil {
return err
}
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.
// Each function collects results into a map; merges happen after both complete
// to avoid concurrent writes to the shared skills slice.
func enrichSkills(client *api.Client, host string, skills []skillResult) {
var descMap map[int]string
var starsMap map[int]int
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
descMap = fetchDescriptions(client, host, skills)
}()
go func() {
defer wg.Done()
starsMap = fetchRepoStars(client, host, skills)
}()
wg.Wait()
for i := range skills {
if desc, ok := descMap[i]; ok {
skills[i].Description = desc
}
if stars, ok := starsMap[i]; ok {
skills[i].Stars = stars
}
}
}
// paginate slices results to the requested page window.
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 qualified skill
// name. Since results are pre-sorted by relevance score, the first occurrences
// are the best instances. This prevents aggregator repos (which copy
// popular skills verbatim) from flooding results while still showing
// a few alternative sources. Namespaced skills (e.g. "author/skill") are
// treated as distinct from bare names.
func deduplicateByName(skills []skillResult) []skillResult {
const maxPerName = 3
counts := make(map[string]int)
var result []skillResult
for _, s := range skills {
key := strings.ToLower(s.qualifiedName())
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(),
text.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.qualifiedName())
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.Muted("★ "+formatStars(s.Stars))
}
descStr := ""
if s.Description != "" {
desc := strings.Join(strings.Fields(s.Description), " ")
descStr = "\n " + cs.Muted(text.Truncate(descWidth, desc))
}
options[i] = s.qualifiedName() + " " + cs.Muted(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]
displayName := s.qualifiedName()
fmt.Fprintf(opts.IO.ErrOut, "\n%s Installing %s from %s...\n",
cs.Blue("::"), displayName, s.Repo)
// Use the repo-relative directory path (e.g. "skills/author/name")
// for disambiguation when installing namespaced skills, so the
// install command can resolve the exact skill without ambiguity.
installArg := s.SkillName
if s.Namespace != "" {
installArg = strings.TrimSuffix(s.Path, "/SKILL.md")
}
//nolint:gosec // arguments are from user-selected search results, not arbitrary input
cmd := exec.Command(opts.Executable, "skills", "install", s.Repo, installArg,
"--agent", host.ID, "--scope", scope)
cmd.Stdin = os.Stdin
cmd.Stdout = opts.IO.Out
cmd.Stderr = opts.IO.ErrOut
if err := cmd.Run(); err != nil {
fmt.Fprintf(opts.IO.ErrOut, "%s Failed to install %s from %s: %s\n",
cs.Red("!"), displayName, 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 (3 000 points)
// - Partial skill name match (1 000 points)
// - Namespace match (500 points)
// - Description contains query (100 points)
// - Repository stars (sqrt bonus, ~2 400 for 6k stars)
func relevanceScore(s skillResult, query string) int {
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 += 3_000
} else if strings.Contains(skillLower, term) || strings.Contains(skillLower, termHyphen) {
score += 1_000
}
// Namespace match.
if s.Namespace != "" && strings.Contains(strings.ToLower(s.Namespace), term) {
score += 500
}
// Description match.
if strings.Contains(strings.ToLower(s.Description), term) {
score += 100
}
// Stars bonus: use √n scaling so popular repos rank meaningfully higher
// without completely drowning out less-popular but more relevant results.
if s.Stars > 0 {
score += int(math.Sqrt(float64(s.Stars)) * 30)
}
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 namespace, the YAML description, or the repository owner or name.
func filterByRelevance(skills []skillResult, query string) []skillResult {
queryTerm := strings.ToLower(query)
termHyphen := strings.ReplaceAll(queryTerm, " ", "-")
filtered := skills[:0] // reuse backing array
for _, s := range skills {
nameLower := strings.ToLower(s.SkillName)
namespaceLower := strings.ToLower(s.Namespace)
descLower := strings.ToLower(s.Description)
ownerLower := strings.ToLower(s.Owner)
repoLower := strings.ToLower(s.RepoName)
if strings.Contains(nameLower, queryTerm) ||
strings.Contains(nameLower, termHyphen) ||
strings.Contains(namespaceLower, queryTerm) ||
strings.Contains(descLower, queryTerm) ||
strings.Contains(ownerLower, queryTerm) ||
strings.Contains(repoLower, queryTerm) {
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, namespace, skill name) triples from code search hits.
func deduplicateResults(items []codeSearchItem) []skillResult {
seen := make(map[string]struct{})
var results []skillResult
for _, item := range items {
skillName, namespace := extractSkillInfo(item.Path)
if skillName == "" {
continue
}
key := item.Repository.FullName + "/" + namespace + "/" + 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,
Namespace: namespace,
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) map[int]string {
const maxWorkers = 10
sem := make(chan struct{}, maxWorkers)
var wg sync.WaitGroup
var mu sync.Mutex
descs := make(map[int]string)
for i := range skills {
if skills[i].BlobSHA == "" {
continue
}
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()
descs[idx] = result.Metadata.Description
mu.Unlock()
}(i)
}
wg.Wait()
return descs
}
// extractSkillInfo derives the skill name and namespace from a SKILL.md path,
// but only if the path matches a known skill convention. Returns empty strings
// for non-conforming paths.
func extractSkillInfo(filePath string) (name, namespace string) {
return discovery.MatchSkillPath(filePath)
}
// formatStars formats a star count for display (e.g. 1700 > "1.7k").
// TODO kw: Could be swapped for go-humanize.
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) map[int]int {
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()
result := make(map[int]int, len(skills))
for i, s := range skills {
if stars, ok := repoStars[s.Repo]; ok {
result[i] = stars
}
}
return result
}

View file

@ -0,0 +1,578 @@
package search
import (
"io"
"net/http"
"strings"
"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 TestSearchRun_UnsupportedHost(t *testing.T) {
ios, _, _, _ := iostreams.Test()
cfg := config.NewBlankConfig()
authCfg := cfg.Authentication()
authCfg.SetDefaultHost("acme.ghes.com", "user")
cfg.AuthenticationFunc = func() gh.AuthConfig {
return authCfg
}
err := searchRun(&SearchOptions{
IO: ios,
Query: "terraform",
Page: 1,
Limit: defaultLimit,
HttpClient: func() (*http.Client, error) { return &http.Client{}, nil },
Config: func() (gh.Config, error) { return cfg, nil },
})
require.ErrorContains(t, err, "supports only github.com")
}
func TestNewCmdSearch(t *testing.T) {
tests := []struct {
name string
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 != "" {
argv = strings.Fields(tt.args)
}
cmd.SetArgs(argv)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
_, err := cmd.ExecuteC()
if tt.wantErr != "" {
require.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\tauthor/my-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: "namespaced skills are kept distinct in same repo",
tty: false,
opts: &SearchOptions{Query: "commit", Page: 1, Limit: defaultLimit},
httpStubs: func(reg *httpmock.Registry) {
stubKeywordSearch(reg, `{"total_count": 2, "incomplete_results": false, "items": [
{"name": "SKILL.md", "path": "skills/kynan/commit/SKILL.md", "repository": {"full_name": "org/skills-repo"}},
{"name": "SKILL.md", "path": "skills/will/commit/SKILL.md", "repository": {"full_name": "org/skills-repo"}}
]}`)
},
wantStdout: "org/skills-repo\tkynan/commit\t\t0\norg/skills-repo\twill/commit\t\t0\n",
},
{
name: "json output with selected fields",
tty: false,
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,
},
{
name: "HTTP 429 returns rate limit error",
tty: false,
opts: &SearchOptions{Query: "terraform", Page: 1, Limit: defaultLimit},
httpStubs: func(reg *httpmock.Registry) {
for range 3 {
reg.Register(
httpmock.REST("GET", "search/code"),
httpmock.StatusStringResponse(429, `{"message": "Too Many Requests"}`),
)
}
},
wantErr: rateLimitErrorMessage,
},
{
name: "HTTP 403 with Retry-After returns rate limit error",
tty: false,
opts: &SearchOptions{Query: "terraform", Page: 1, Limit: defaultLimit},
httpStubs: func(reg *httpmock.Registry) {
for range 3 {
reg.Register(
httpmock.REST("GET", "search/code"),
httpmock.WithHeader(
httpmock.StatusJSONResponse(403, map[string]string{"message": "secondary rate limit"}),
"Retry-After", "60",
),
)
}
},
wantErr: rateLimitErrorMessage,
},
{
name: "no results with owner scope",
tty: true,
opts: &SearchOptions{Query: "nonexistent", Owner: "monalisa", Page: 1, Limit: defaultLimit},
httpStubs: func(reg *httpmock.Registry) {
// With --owner set, only path + primary searches fire (no owner search).
for range 2 {
reg.Register(
httpmock.REST("GET", "search/code"),
httpmock.StringResponse(emptyCodeResponse),
)
}
},
wantErr: `no skills found matching "nonexistent" from owner "monalisa"`,
},
{
name: "enriches results with blob descriptions",
tty: false,
opts: &SearchOptions{Query: "terraform", Page: 1, Limit: defaultLimit},
httpStubs: func(reg *httpmock.Registry) {
codeResponse := `{"total_count": 1, "incomplete_results": false, "items": [
{"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "sha": "abc123",
"repository": {"full_name": "org/repo"}}
]}`
stubKeywordSearch(reg, codeResponse)
// Blob fetch for description enrichment
reg.Register(
httpmock.REST("GET", "repos/org/repo/git/blobs/abc123"),
httpmock.JSONResponse(map[string]string{
"content": "LS0tCmRlc2NyaXB0aW9uOiBBdXRvbWF0ZXMgVGVycmFmb3JtIGluZnJhc3RydWN0dXJlCi0tLQojIFRlcnJhZm9ybSBTa2lsbAo=",
"encoding": "base64",
}),
)
// Repo stars fetch
reg.Register(
httpmock.REST("GET", "repos/org/repo"),
httpmock.JSONResponse(map[string]int{"stargazers_count": 42}),
)
},
wantStdout: "org/repo\tterraform\tAutomates Terraform infrastructure\t42\n",
},
}
for _, tt := range tests {
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 TestDeduplicateResults_Namespaced(t *testing.T) {
items := []codeSearchItem{
{Path: "skills/kynan/commit/SKILL.md", Repository: codeSearchRepository{FullName: "org/repo"}},
{Path: "skills/will/commit/SKILL.md", Repository: codeSearchRepository{FullName: "org/repo"}},
{Path: "skills/kynan/commit/SKILL.md", Repository: codeSearchRepository{FullName: "org/repo"}}, // duplicate
{Path: "skills/commit/SKILL.md", Repository: codeSearchRepository{FullName: "org/repo"}}, // non-namespaced
}
results := deduplicateResults(items)
require.Equal(t, 3, len(results))
assert.Equal(t, "commit", results[0].SkillName)
assert.Equal(t, "kynan", results[0].Namespace)
assert.Equal(t, "commit", results[1].SkillName)
assert.Equal(t, "will", results[1].Namespace)
assert.Equal(t, "commit", results[2].SkillName)
assert.Equal(t, "", results[2].Namespace)
}
func TestExtractSkillInfo(t *testing.T) {
tests := []struct {
path string
wantName string
wantNamespace string
}{
{"skills/terraform/SKILL.md", "terraform", ""},
{"skills/author/my-skill/SKILL.md", "my-skill", "author"},
{"SKILL.md", "", ""},
{"skills/docker/SKILL.md", "docker", ""},
// Root-level convention
{"my-skill/SKILL.md", "my-skill", ""},
// Plugins convention
{"plugins/openai/skills/chat/SKILL.md", "chat", "openai"},
// Non-matching paths should be filtered out
{"random/nested/deep/SKILL.md", "", ""},
{".hidden/SKILL.md", "", ""},
// Same-name skills with different namespaces
{"skills/kynan/commit/SKILL.md", "commit", "kynan"},
{"skills/will/commit/SKILL.md", "commit", "will"},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
gotName, gotNamespace := extractSkillInfo(tt.path)
assert.Equal(t, tt.wantName, gotName)
assert.Equal(t, tt.wantNamespace, gotNamespace)
})
}
}
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"},
{Repo: "org/repo3", Owner: "org", RepoName: "repo3", SkillName: "deploy", Namespace: "terraform"},
}
filtered := filterByRelevance(skills, "terraform")
// Should keep: name match (terraform), owner match (terraform-corp),
// repo name match (terraform-tools), description match (terraform integration),
// namespace match (terraform/deploy).
// Should drop: docker, noise.
assert.Equal(t, 5, len(filtered))
assert.Equal(t, "terraform", filtered[0].SkillName)
assert.Equal(t, "linter", filtered[1].SkillName)
assert.Equal(t, "validator", filtered[2].SkillName)
assert.Equal(t, "unrelated", filtered[3].SkillName)
assert.Equal(t, "deploy", filtered[4].SkillName)
assert.Equal(t, "terraform", filtered[4].Namespace)
}
func TestRankByRelevance(t *testing.T) {
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 (3 000), then partial name (1 000),
// then description match (100), then body-only (0).
assert.Equal(t, "terraform", skills[0].SkillName)
assert.Equal(t, "terraform-plan", skills[1].SkillName)
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 TestQualifiedName(t *testing.T) {
tests := []struct {
name string
skill skillResult
want string
}{
{
name: "no namespace",
skill: skillResult{SkillName: "terraform"},
want: "terraform",
},
{
name: "with namespace",
skill: skillResult{SkillName: "commit", Namespace: "kynan"},
want: "kynan/commit",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, tt.skill.qualifiedName())
})
}
}
func TestDeduplicateByName_Namespaced(t *testing.T) {
// Skills with the same base name but different namespaces should
// be treated as distinct and not collapsed against each other.
skills := []skillResult{
{Repo: "org/repo1", SkillName: "commit", Namespace: "kynan"},
{Repo: "org/repo2", SkillName: "commit", Namespace: "will"},
{Repo: "org/repo3", SkillName: "commit"},
{Repo: "org/repo4", SkillName: "commit", Namespace: "kynan"},
{Repo: "org/repo5", SkillName: "commit", Namespace: "kynan"},
{Repo: "org/repo6", SkillName: "commit", Namespace: "kynan"}, // should be capped (4th kynan/commit)
}
result := deduplicateByName(skills)
// kynan/commit capped at 3, will/commit has 1, bare commit has 1 = 5 total
require.Equal(t, 5, len(result))
assert.Equal(t, "kynan", result[0].Namespace)
assert.Equal(t, "will", result[1].Namespace)
assert.Equal(t, "", result[2].Namespace)
assert.Equal(t, "kynan", result[3].Namespace)
assert.Equal(t, "kynan", result[4].Namespace)
// repo6 should have been dropped
for _, s := range result {
assert.NotEqual(t, "org/repo6", s.Repo)
}
}

52
pkg/cmd/skills/skills.go Normal file
View file

@ -0,0 +1,52 @@
package skills
import (
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/pkg/cmd/skills/install"
"github.com/cli/cli/v2/pkg/cmd/skills/preview"
"github.com/cli/cli/v2/pkg/cmd/skills/publish"
"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"
)
// NewCmdSkills returns the top-level "skill" command.
func NewCmdSkills(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "skill <command>",
Short: "Install and manage agent skills (preview)",
Long: heredoc.Doc(`
Install and manage agent skills from GitHub repositories.
Working with agent skills in the GitHub CLI is in preview and
subject to change without notice.
`),
Aliases: []string{"skills"},
GroupID: "core",
Example: heredoc.Doc(`
# Search for skills
$ gh skill search terraform
# Install a skill
$ gh skill install github/awesome-copilot documentation-writer
# Preview a skill before installing
$ gh skill preview github/awesome-copilot documentation-writer
# Update all installed skills
$ gh skill update --all
# Validate skills for publishing
$ gh skill publish --dry-run
`),
}
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,568 @@
package update
import (
"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/internal/skills/source"
"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
Skills []string
All bool
Force bool
DryRun bool
Unpin bool
Dir string
}
// installedSkill represents a locally installed skill parsed from its SKILL.md frontmatter.
type installedSkill struct {
name string
repoHost string
owner string
repo string
treeSHA string // tree SHA at install time
pinned string // explicit pin value (empty = unpinned)
sourcePath string // original path in source repo (e.g. "skills/author/name")
dir string // local directory path
host *registry.AgentHost
scope registry.Scope
metadataErr error
}
// pendingUpdate describes a single skill that has an available update.
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>...] [flags]",
Short: "Update installed skills to their latest versions (preview)",
Long: heredoc.Docf(`
Checks installed skills for available updates by comparing the local
tree SHA (from %[1]sSKILL.md%[1]s frontmatter) against the remote repository.
Scans all known agent host directories (Copilot, Claude, Cursor, Codex,
Gemini, Antigravity) in both project and user scope automatically.
Without arguments, checks all installed skills. With skill names,
checks only those specific skills.
Pinned skills (installed with %[1]s--pin%[1]s) are skipped with a notice.
Use %[1]s--unpin%[1]s to clear the pinned version and include those skills
in the update.
Skills without GitHub metadata (e.g. installed manually or by another
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 %[1]s--force%[1]s, re-downloads skills even when the remote version matches
the local tree SHA. This overwrites locally modified skill files with
their original content, but does not remove extra files added locally.
In interactive mode, shows which skills have updates and asks for
confirmation before proceeding. With %[1]s--all%[1]s, updates without prompting.
With %[1]s--dry-run%[1]s, reports available updates without modifying any files.
`, "`"),
Example: heredoc.Doc(`
# Check and update all skills interactively
$ gh skill update
# Update specific skills
$ gh skill update mcp-cli git-commit
# Update all without prompting
$ gh skill update --all
# Re-download all skills (restore locally modified files)
$ gh skill update --force --all
# Check for updates without applying (read-only)
$ gh skill update --dry-run
# Unpin skills and update them to latest
$ gh skill update --unpin
`),
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().BoolVar(&opts.Unpin, "unpin", false, "Clear pinned version and include pinned skills in update")
cmd.Flags().StringVar(&opts.Dir, "dir", "", "Scan a custom directory for installed skills")
return cmd
}
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)
gitRoot := installer.ResolveGitRoot(opts.GitClient)
homeDir := installer.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 = scanAllAgents(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
}
// Skip skills with invalid metadata rather than aborting the entire
// update run. One corrupt skill should not prevent updating others.
{
var valid []installedSkill
for _, s := range installed {
if s.metadataErr != nil {
fmt.Fprintf(opts.IO.ErrOut, "%s Skipping %s: invalid repository metadata: %s\n", cs.WarningIcon(), s.name, s.metadataErr)
continue
}
valid = append(valid, s)
}
installed = valid
}
if len(installed) == 0 {
fmt.Fprintf(opts.IO.ErrOut, "No updatable skills found.\n")
return nil
}
// Prompt for metadata on skills missing it (before starting progress indicator)
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
s.repoHost = source.SupportedHost
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{ host, 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 != "" && !opts.Unpin {
pinned = append(pinned, s)
continue
}
key := repoKey{s.repoHost, 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, s.repoHost, 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, s.repoHost, 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{source.SupportedHost, 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.Muted("⊘"), s.name, s.pinned)
}
for _, name := range noMeta {
fmt.Fprintf(opts.IO.ErrOut, "%s %s has no GitHub metadata. Reinstall to enable updates\n", cs.WarningIcon(), name)
}
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), discovery.ShortRef(u.resolved.Ref))
} else {
fmt.Fprintf(opts.IO.Out, " %s %s (%s/%s) %s > %s [%s]\n",
cs.Cyan("•"), u.local.name, u.local.owner, u.local.repo,
cs.Muted(git.ShortSHA(u.local.treeSHA)), git.ShortSHA(u.newSHA),
discovery.ShortRef(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 cmdutil.CancelError
}
}
var failed bool
for _, u := range updates {
installOpts := &installer.Options{
Host: u.local.repoHost,
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
}
// scanAllAgents walks every registered agent's skill directory (project + user scope) and
// collects installed skills. Shared install roots are scanned only once.
func scanAllAgents(gitRoot, homeDir string) []installedSkill {
scannedDirs := 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
}
if scannedDirs[dir] {
continue
}
scannedDirs[dir] = true
skills, err := scanInstalledSkills(dir, host, scope)
if err != nil {
continue
}
all = append(all, skills...)
}
}
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{
name: name,
dir: dir,
host: host,
scope: scope,
metadataErr: fmt.Errorf("invalid SKILL.md: %w", err),
}, true
}
s := installedSkill{
name: name,
dir: dir,
host: host,
scope: scope,
}
if result.Metadata.Meta != nil {
repoInfo, ok, repoErr := source.ParseMetadataRepo(result.Metadata.Meta)
if repoErr != nil {
s.metadataErr = repoErr
} else if ok {
if err := source.ValidateSupportedHost(repoInfo.RepoHost()); err != nil {
s.metadataErr = err
} else {
s.repoHost = repoInfo.RepoHost()
s.owner = repoInfo.RepoOwner()
s.repo = repoInfo.RepoName()
}
}
s.treeSHA, _ = result.Metadata.Meta["github-tree-sha"].(string)
s.pinned, _ = result.Metadata.Meta["github-pinned"].(string)
s.sourcePath, _ = result.Metadata.Meta["github-path"].(string)
}
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
}

File diff suppressed because it is too large Load diff