Merge pull request #13165 from cli/sm/add-skills-command
Add agent skills command
This commit is contained in:
commit
97af1a5eb7
55 changed files with 15303 additions and 2 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -38,3 +38,4 @@
|
|||
*~
|
||||
|
||||
vendor/
|
||||
gh
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
|||
11
acceptance/testdata/skills/skills-install-force.txtar
vendored
Normal file
11
acceptance/testdata/skills/skills-install-force.txtar
vendored
Normal 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'
|
||||
15
acceptance/testdata/skills/skills-install-from-local.txtar
vendored
Normal file
15
acceptance/testdata/skills/skills-install-from-local.txtar
vendored
Normal 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.
|
||||
4
acceptance/testdata/skills/skills-install-invalid-agent.txtar
vendored
Normal file
4
acceptance/testdata/skills/skills-install-invalid-agent.txtar
vendored
Normal 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'
|
||||
3
acceptance/testdata/skills/skills-install-invalid-repo.txtar
vendored
Normal file
3
acceptance/testdata/skills/skills-install-invalid-repo.txtar
vendored
Normal 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'
|
||||
60
acceptance/testdata/skills/skills-install-namespaced.txtar
vendored
Normal file
60
acceptance/testdata/skills/skills-install-namespaced.txtar
vendored
Normal 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.
|
||||
3
acceptance/testdata/skills/skills-install-nested-files.txtar
vendored
Normal file
3
acceptance/testdata/skills/skills-install-nested-files.txtar
vendored
Normal 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
|
||||
3
acceptance/testdata/skills/skills-install-nonexistent-skill.txtar
vendored
Normal file
3
acceptance/testdata/skills/skills-install-nonexistent-skill.txtar
vendored
Normal 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'
|
||||
7
acceptance/testdata/skills/skills-install-pin.txtar
vendored
Normal file
7
acceptance/testdata/skills/skills-install-pin.txtar
vendored
Normal 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'
|
||||
9
acceptance/testdata/skills/skills-install-scope.txtar
vendored
Normal file
9
acceptance/testdata/skills/skills-install-scope.txtar
vendored
Normal 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
|
||||
20
acceptance/testdata/skills/skills-install.txtar
vendored
Normal file
20
acceptance/testdata/skills/skills-install.txtar
vendored
Normal 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
|
||||
3
acceptance/testdata/skills/skills-preview-noninteractive.txtar
vendored
Normal file
3
acceptance/testdata/skills/skills-preview-noninteractive.txtar
vendored
Normal 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'
|
||||
9
acceptance/testdata/skills/skills-preview.txtar
vendored
Normal file
9
acceptance/testdata/skills/skills-preview.txtar
vendored
Normal 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'
|
||||
58
acceptance/testdata/skills/skills-publish-dir-remote.txtar
vendored
Normal file
58
acceptance/testdata/skills/skills-publish-dir-remote.txtar
vendored
Normal 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.
|
||||
33
acceptance/testdata/skills/skills-publish-dry-run.txtar
vendored
Normal file
33
acceptance/testdata/skills/skills-publish-dry-run.txtar
vendored
Normal 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!"
|
||||
64
acceptance/testdata/skills/skills-publish-lifecycle.txtar
vendored
Normal file
64
acceptance/testdata/skills/skills-publish-lifecycle.txtar
vendored
Normal 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."
|
||||
4
acceptance/testdata/skills/skills-search-noresults.txtar
vendored
Normal file
4
acceptance/testdata/skills/skills-search-noresults.txtar
vendored
Normal 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 .
|
||||
3
acceptance/testdata/skills/skills-search-page.txtar
vendored
Normal file
3
acceptance/testdata/skills/skills-search-page.txtar
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Pagination returns results on page 2
|
||||
exec gh skill search copilot --page 2
|
||||
stdout 'copilot'
|
||||
12
acceptance/testdata/skills/skills-search.txtar
vendored
Normal file
12
acceptance/testdata/skills/skills-search.txtar
vendored
Normal 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'
|
||||
5
acceptance/testdata/skills/skills-update-noinstalled.txtar
vendored
Normal file
5
acceptance/testdata/skills/skills-update-noinstalled.txtar
vendored
Normal 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 --
|
||||
22
acceptance/testdata/skills/skills-update.txtar
vendored
Normal file
22
acceptance/testdata/skills/skills-update.txtar
vendored
Normal 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
|
||||
|
|
@ -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...)
|
||||
|
|
|
|||
|
|
@ -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
2
go.mod
|
|
@ -57,6 +57,7 @@ require (
|
|||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/sys v0.43.0
|
||||
golang.org/x/term v0.42.0
|
||||
golang.org/x/text v0.36.0
|
||||
google.golang.org/grpc v1.80.0
|
||||
|
|
@ -182,7 +183,6 @@ require (
|
|||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect
|
||||
|
|
|
|||
8
internal/flock/flock.go
Normal file
8
internal/flock/flock.go
Normal 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")
|
||||
99
internal/flock/flock_test.go
Normal file
99
internal/flock/flock_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
32
internal/flock/flock_unix.go
Normal file
32
internal/flock/flock_unix.go
Normal 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
|
||||
}
|
||||
41
internal/flock/flock_windows.go
Normal file
41
internal/flock/flock_windows.go
Normal 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
|
||||
}
|
||||
53
internal/skills/discovery/collisions.go
Normal file
53
internal/skills/discovery/collisions.go
Normal 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 ")
|
||||
}
|
||||
80
internal/skills/discovery/collisions_test.go
Normal file
80
internal/skills/discovery/collisions_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
810
internal/skills/discovery/discovery.go
Normal file
810
internal/skills/discovery/discovery.go
Normal 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)
|
||||
}
|
||||
1157
internal/skills/discovery/discovery_test.go
Normal file
1157
internal/skills/discovery/discovery_test.go
Normal file
File diff suppressed because it is too large
Load diff
149
internal/skills/frontmatter/frontmatter.go
Normal file
149
internal/skills/frontmatter/frontmatter.go
Normal 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
|
||||
}
|
||||
255
internal/skills/frontmatter/frontmatter_test.go
Normal file
255
internal/skills/frontmatter/frontmatter_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
327
internal/skills/installer/installer.go
Normal file
327
internal/skills/installer/installer.go
Normal 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
|
||||
}
|
||||
518
internal/skills/installer/installer_test.go
Normal file
518
internal/skills/installer/installer_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
177
internal/skills/lockfile/lockfile.go
Normal file
177
internal/skills/lockfile/lockfile.go
Normal 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)
|
||||
}
|
||||
203
internal/skills/lockfile/lockfile_test.go
Normal file
203
internal/skills/lockfile/lockfile_test.go
Normal 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
|
||||
}
|
||||
173
internal/skills/registry/registry.go
Normal file
173
internal/skills/registry/registry.go
Normal 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)
|
||||
}
|
||||
205
internal/skills/registry/registry_test.go
Normal file
205
internal/skills/registry/registry_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
66
internal/skills/source/source.go
Normal file
66
internal/skills/source/source.go
Normal 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.")
|
||||
}
|
||||
76
internal/skills/source/source_test.go
Normal file
76
internal/skills/source/source_test.go
Normal 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")
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
994
pkg/cmd/skills/install/install.go
Normal file
994
pkg/cmd/skills/install/install.go
Normal 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] = ®istry.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)
|
||||
}
|
||||
1982
pkg/cmd/skills/install/install_test.go
Normal file
1982
pkg/cmd/skills/install/install_test.go
Normal file
File diff suppressed because it is too large
Load diff
439
pkg/cmd/skills/preview/preview.go
Normal file
439
pkg/cmd/skills/preview/preview.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
804
pkg/cmd/skills/preview/preview_test.go
Normal file
804
pkg/cmd/skills/preview/preview_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
1132
pkg/cmd/skills/publish/publish.go
Normal file
1132
pkg/cmd/skills/publish/publish.go
Normal file
File diff suppressed because it is too large
Load diff
1692
pkg/cmd/skills/publish/publish_test.go
Normal file
1692
pkg/cmd/skills/publish/publish_test.go
Normal file
File diff suppressed because it is too large
Load diff
915
pkg/cmd/skills/search/search.go
Normal file
915
pkg/cmd/skills/search/search.go
Normal 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
|
||||
}
|
||||
578
pkg/cmd/skills/search/search_test.go
Normal file
578
pkg/cmd/skills/search/search_test.go
Normal 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
52
pkg/cmd/skills/skills.go
Normal 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
|
||||
}
|
||||
568
pkg/cmd/skills/update/update.go
Normal file
568
pkg/cmd/skills/update/update.go
Normal 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 := ®istry.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
|
||||
}
|
||||
1196
pkg/cmd/skills/update/update_test.go
Normal file
1196
pkg/cmd/skills/update/update_test.go
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue