diff --git a/.gitignore b/.gitignore index b82a00c72..ffcbbb6c5 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ *~ vendor/ +gh diff --git a/acceptance/acceptance_test.go b/acceptance/acceptance_test.go index 98642afaf..7c3c6f6ce 100644 --- a/acceptance/acceptance_test.go +++ b/acceptance/acceptance_test.go @@ -14,9 +14,9 @@ import ( "math/rand" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/ghcmd" "github.com/cli/go-internal/testscript" - "github.com/MakeNowJust/heredoc" ) func ghMain() int { @@ -434,3 +434,11 @@ func (e *testScriptEnv) fromEnv() error { return nil } + +func TestSkills(t *testing.T) { + var tsEnv testScriptEnv + if err := tsEnv.fromEnv(); err != nil { + t.Fatal(err) + } + testscript.Run(t, testScriptParamsFor(tsEnv, "skills")) +} diff --git a/acceptance/testdata/skills/skills-install-force.txtar b/acceptance/testdata/skills/skills-install-force.txtar new file mode 100644 index 000000000..e6bd520b9 --- /dev/null +++ b/acceptance/testdata/skills/skills-install-force.txtar @@ -0,0 +1,11 @@ +# Install with --force should overwrite an existing skill without error +exec gh 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' diff --git a/acceptance/testdata/skills/skills-install-from-local.txtar b/acceptance/testdata/skills/skills-install-from-local.txtar new file mode 100644 index 000000000..0b003fd3e --- /dev/null +++ b/acceptance/testdata/skills/skills-install-from-local.txtar @@ -0,0 +1,15 @@ +# Install from a local directory using --from-local +exec gh skill install --from-local $WORK/local-repo git-commit --dir $WORK/output --force +stdout 'Installed git-commit' + +# Verify the skill was copied +exists $WORK/output/git-commit/SKILL.md +grep 'local-path' $WORK/output/git-commit/SKILL.md + +-- local-repo/skills/git-commit/SKILL.md -- +--- +name: git-commit +description: Write good git commits +--- +# Git Commit +Body content. diff --git a/acceptance/testdata/skills/skills-install-invalid-agent.txtar b/acceptance/testdata/skills/skills-install-invalid-agent.txtar new file mode 100644 index 000000000..7e85a9fae --- /dev/null +++ b/acceptance/testdata/skills/skills-install-invalid-agent.txtar @@ -0,0 +1,4 @@ +# Invalid agent ID should error with valid options +! exec gh skill install github/awesome-copilot git-commit --agent bogus-agent --force +stderr 'invalid argument' +stderr 'github-copilot' diff --git a/acceptance/testdata/skills/skills-install-invalid-repo.txtar b/acceptance/testdata/skills/skills-install-invalid-repo.txtar new file mode 100644 index 000000000..2b59582e1 --- /dev/null +++ b/acceptance/testdata/skills/skills-install-invalid-repo.txtar @@ -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' diff --git a/acceptance/testdata/skills/skills-install-namespaced.txtar b/acceptance/testdata/skills/skills-install-namespaced.txtar new file mode 100644 index 000000000..db39bead0 --- /dev/null +++ b/acceptance/testdata/skills/skills-install-namespaced.txtar @@ -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. diff --git a/acceptance/testdata/skills/skills-install-nested-files.txtar b/acceptance/testdata/skills/skills-install-nested-files.txtar new file mode 100644 index 000000000..c4fe085e4 --- /dev/null +++ b/acceptance/testdata/skills/skills-install-nested-files.txtar @@ -0,0 +1,3 @@ +# Install a skill that has nested subdirectories and verify file tree +exec gh skill install github/awesome-copilot git-commit --force --dir $WORK/nested-test +exists $WORK/nested-test/git-commit/SKILL.md diff --git a/acceptance/testdata/skills/skills-install-nonexistent-skill.txtar b/acceptance/testdata/skills/skills-install-nonexistent-skill.txtar new file mode 100644 index 000000000..44187c4ff --- /dev/null +++ b/acceptance/testdata/skills/skills-install-nonexistent-skill.txtar @@ -0,0 +1,3 @@ +# Installing a skill that doesn't exist in a valid repo should error +! exec gh skill install github/awesome-copilot nonexistent-skill-xyz --force --dir $WORK/tmp +stderr 'not found' diff --git a/acceptance/testdata/skills/skills-install-pin.txtar b/acceptance/testdata/skills/skills-install-pin.txtar new file mode 100644 index 000000000..7c87e4b33 --- /dev/null +++ b/acceptance/testdata/skills/skills-install-pin.txtar @@ -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' diff --git a/acceptance/testdata/skills/skills-install-scope.txtar b/acceptance/testdata/skills/skills-install-scope.txtar new file mode 100644 index 000000000..52270178a --- /dev/null +++ b/acceptance/testdata/skills/skills-install-scope.txtar @@ -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 diff --git a/acceptance/testdata/skills/skills-install.txtar b/acceptance/testdata/skills/skills-install.txtar new file mode 100644 index 000000000..c365cb833 --- /dev/null +++ b/acceptance/testdata/skills/skills-install.txtar @@ -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 diff --git a/acceptance/testdata/skills/skills-preview-noninteractive.txtar b/acceptance/testdata/skills/skills-preview-noninteractive.txtar new file mode 100644 index 000000000..7c276b8d3 --- /dev/null +++ b/acceptance/testdata/skills/skills-preview-noninteractive.txtar @@ -0,0 +1,3 @@ +# Preview with repo only and non-interactive should error +! exec gh skill preview github/awesome-copilot +stderr 'must specify a skill name' diff --git a/acceptance/testdata/skills/skills-preview.txtar b/acceptance/testdata/skills/skills-preview.txtar new file mode 100644 index 000000000..be1be5244 --- /dev/null +++ b/acceptance/testdata/skills/skills-preview.txtar @@ -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' diff --git a/acceptance/testdata/skills/skills-publish-dir-remote.txtar b/acceptance/testdata/skills/skills-publish-dir-remote.txtar new file mode 100644 index 000000000..8f833a76c --- /dev/null +++ b/acceptance/testdata/skills/skills-publish-dir-remote.txtar @@ -0,0 +1,58 @@ +# When a directory argument is provided to `gh skill publish --dry-run`, +# the remote detection must use the target directory's git remotes, +# not the current working directory's remotes. +# +# This test creates two separate git repos: +# - cwd-repo (the working directory) with remote pointing to owner/cwd-repo +# - target-repo (the dir argument) with remote pointing to owner/target-repo +# +# If the bug is present, the command would detect cwd-repo's remote instead of +# target-repo's remote. + +# Set up credential helper +exec gh auth setup-git + +# Create two test repos on GitHub +exec gh repo create $ORG/$SCRIPT_NAME-cwd-$RANDOM_STRING --private --add-readme +defer gh repo delete --yes $ORG/$SCRIPT_NAME-cwd-$RANDOM_STRING + +exec gh repo create $ORG/$SCRIPT_NAME-target-$RANDOM_STRING --private --add-readme +defer gh repo delete --yes $ORG/$SCRIPT_NAME-target-$RANDOM_STRING + +# Clone both repos +exec gh repo clone $ORG/$SCRIPT_NAME-cwd-$RANDOM_STRING cwd-repo +exec gh repo clone $ORG/$SCRIPT_NAME-target-$RANDOM_STRING target-repo + +# Add a skill to the target repo only +mkdir target-repo/skills/hello-world +cp $WORK/skill.md target-repo/skills/hello-world/SKILL.md +exec git -C $WORK/target-repo add -A +exec git -C $WORK/target-repo commit -m 'Add test skill' +exec git -C $WORK/target-repo push origin main + +# Run publish dry-run from cwd-repo, pointing at target-repo +cd cwd-repo +exec gh skill publish --dry-run $WORK/target-repo + +# Verify the output references the target repo, not the cwd repo +stdout 'hello-world' + +# Publish with a tag from within cwd-repo, targeting target-repo +exec gh skill publish --tag v0.1.0 $WORK/target-repo + +# Verify the release was created on the TARGET repo, not the cwd repo +exec gh release view v0.1.0 --repo $ORG/$SCRIPT_NAME-target-$RANDOM_STRING +stdout 'v0.1.0' + +# Verify NO release was created on the cwd repo +! exec gh release view v0.1.0 --repo $ORG/$SCRIPT_NAME-cwd-$RANDOM_STRING + +-- skill.md -- +--- +name: hello-world +description: A test skill that greets the user. +--- + +# Hello World + +Greet the user warmly. diff --git a/acceptance/testdata/skills/skills-publish-dry-run.txtar b/acceptance/testdata/skills/skills-publish-dry-run.txtar new file mode 100644 index 000000000..cb32fa7e2 --- /dev/null +++ b/acceptance/testdata/skills/skills-publish-dry-run.txtar @@ -0,0 +1,33 @@ +# Publish dry-run from a directory with no skills/ should fail gracefully +! exec gh 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!" diff --git a/acceptance/testdata/skills/skills-publish-lifecycle.txtar b/acceptance/testdata/skills/skills-publish-lifecycle.txtar new file mode 100644 index 000000000..d3d6f0a3a --- /dev/null +++ b/acceptance/testdata/skills/skills-publish-lifecycle.txtar @@ -0,0 +1,64 @@ +# Full publish lifecycle: create repo, publish, install from it, clean up + +# Use gh as a credential helper +exec gh auth setup-git + +# Create a private repo for testing +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --private --add-readme +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING +cd $SCRIPT_NAME-$RANDOM_STRING + +# Add a test skill +mkdir skills/hello-world/scripts +cp $WORK/skill.md skills/hello-world/SKILL.md +cp $WORK/setup.sh skills/hello-world/scripts/setup.sh +exec git add -A +exec git commit -m 'Add test skill' +exec git push origin main + +# Publish with a tag +exec gh 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." diff --git a/acceptance/testdata/skills/skills-search-noresults.txtar b/acceptance/testdata/skills/skills-search-noresults.txtar new file mode 100644 index 000000000..c51d7b568 --- /dev/null +++ b/acceptance/testdata/skills/skills-search-noresults.txtar @@ -0,0 +1,4 @@ +# Search for something unlikely to exist returns empty stdout +# NoResultsError is silent in non-TTY (exits 0 with no output) +exec gh skill search zzzznonexistenttotallyfakeskillxyz123 +! stdout . diff --git a/acceptance/testdata/skills/skills-search-page.txtar b/acceptance/testdata/skills/skills-search-page.txtar new file mode 100644 index 000000000..30c044f78 --- /dev/null +++ b/acceptance/testdata/skills/skills-search-page.txtar @@ -0,0 +1,3 @@ +# Pagination returns results on page 2 +exec gh skill search copilot --page 2 +stdout 'copilot' diff --git a/acceptance/testdata/skills/skills-search.txtar b/acceptance/testdata/skills/skills-search.txtar new file mode 100644 index 000000000..5e8c77442 --- /dev/null +++ b/acceptance/testdata/skills/skills-search.txtar @@ -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' diff --git a/acceptance/testdata/skills/skills-update-noinstalled.txtar b/acceptance/testdata/skills/skills-update-noinstalled.txtar new file mode 100644 index 000000000..7fd19541b --- /dev/null +++ b/acceptance/testdata/skills/skills-update-noinstalled.txtar @@ -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 -- diff --git a/acceptance/testdata/skills/skills-update.txtar b/acceptance/testdata/skills/skills-update.txtar new file mode 100644 index 000000000..52933a5f8 --- /dev/null +++ b/acceptance/testdata/skills/skills-update.txtar @@ -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 diff --git a/git/client.go b/git/client.go index 5f547c99c..7f2487fce 100644 --- a/git/client.go +++ b/git/client.go @@ -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...) diff --git a/git/client_test.go b/git/client_test.go index f59b26077..7ffee2dc9 100644 --- a/git/client_test.go +++ b/git/client_test.go @@ -2164,3 +2164,123 @@ func createMockedCommandContext(t *testing.T, commands mockedCommands) commandCt return cmd } } + +func TestClientRemoteURL(t *testing.T) { + tests := []struct { + name string + cmdExitStatus int + cmdStdout string + cmdStderr string + wantCmdArgs string + wantURL string + wantErrorMsg string + }{ + { + name: "returns remote URL", + cmdStdout: "https://github.com/monalisa/skills-repo.git\n", + wantCmdArgs: "path/to/git remote get-url -- origin", + wantURL: "https://github.com/monalisa/skills-repo.git", + }, + { + name: "git error", + cmdExitStatus: 1, + cmdStderr: "fatal: No such remote 'nonexistent'", + wantCmdArgs: "path/to/git remote get-url -- nonexistent", + wantErrorMsg: "failed to run git: fatal: No such remote 'nonexistent'", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr) + client := Client{ + GitPath: "path/to/git", + commandContext: cmdCtx, + } + remoteName := "origin" + if tt.wantErrorMsg != "" { + remoteName = "nonexistent" + } + url, err := client.RemoteURL(context.Background(), remoteName) + assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " ")) + if tt.wantErrorMsg == "" { + assert.NoError(t, err) + assert.Equal(t, tt.wantURL, url) + } else { + assert.EqualError(t, err, tt.wantErrorMsg) + } + }) + } + + // Covers the early return in RemoteURL when Command() itself fails. + // (e.g. git binary not resolvable). + t.Run("returns error when git has a fatal error", func(t *testing.T) { + t.Setenv("PATH", "") + client := Client{} + _, err := client.RemoteURL(context.Background(), "origin") + assert.Error(t, err) + }) +} + +func TestClientIsIgnored(t *testing.T) { + tests := []struct { + name string + cmdExitStatus int + cmdStdout string + cmdStderr string + wantCmdArgs string + wantIgnored bool + wantErr bool + }{ + { + name: "path is ignored", + wantCmdArgs: "path/to/git check-ignore -q -- .github/skills", + wantIgnored: true, + }, + { + name: "path is not ignored", + cmdExitStatus: 1, + wantCmdArgs: "path/to/git check-ignore -q -- .github/skills", + wantIgnored: false, + }, + { + name: "fatal git error", + cmdExitStatus: 128, + cmdStderr: "fatal: not a git repository", + wantCmdArgs: "path/to/git check-ignore -q -- .github/skills", + wantIgnored: false, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr) + client := Client{ + GitPath: "path/to/git", + commandContext: cmdCtx, + } + ignored, err := client.IsIgnored(context.Background(), ".github/skills") + assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " ")) + assert.Equal(t, tt.wantIgnored, ignored) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } + + // Covers the early return in IsIgnored when Command() itself fails + // (e.g. git binary not resolvable). + t.Run("returns error when git has a fatal error", func(t *testing.T) { + t.Setenv("PATH", "") + client := Client{} + ignored, err := client.IsIgnored(context.Background(), ".github/skills") + assert.False(t, ignored) + assert.Error(t, err) + }) +} + +func TestShortSHA(t *testing.T) { + assert.Equal(t, "abc123de", ShortSHA("abc123def456789")) + assert.Equal(t, "short", ShortSHA("short")) +} diff --git a/go.mod b/go.mod index 615b1ebf4..0fc0b1a5e 100644 --- a/go.mod +++ b/go.mod @@ -57,6 +57,7 @@ require ( github.com/zalando/go-keyring v0.2.8 golang.org/x/crypto v0.50.0 golang.org/x/sync v0.20.0 + golang.org/x/sys v0.43.0 golang.org/x/term v0.42.0 golang.org/x/text v0.36.0 google.golang.org/grpc v1.80.0 @@ -182,7 +183,6 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/mod v0.34.0 // indirect golang.org/x/net v0.53.0 // indirect - golang.org/x/sys v0.43.0 // indirect golang.org/x/tools v0.43.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect diff --git a/internal/flock/flock.go b/internal/flock/flock.go new file mode 100644 index 000000000..6d5af9f01 --- /dev/null +++ b/internal/flock/flock.go @@ -0,0 +1,8 @@ +package flock + +import "errors" + +// ErrLocked is returned when the file is already locked by another process. +// Callers can check for this to distinguish contention from permanent errors. +// This is intended to be an OS-agnostic sentinel error. +var ErrLocked = errors.New("file is locked by another process") diff --git a/internal/flock/flock_test.go b/internal/flock/flock_test.go new file mode 100644 index 000000000..69b3a73b5 --- /dev/null +++ b/internal/flock/flock_test.go @@ -0,0 +1,99 @@ +package flock_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/cli/cli/v2/internal/flock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTryLock(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) string // returns lock path + wantErr error + verify func(t *testing.T, f *os.File) + }{ + { + name: "acquires lock and returns writable file handle", + setup: func(t *testing.T) string { + return filepath.Join(t.TempDir(), "test.lock") + }, + verify: func(t *testing.T, f *os.File) { + t.Helper() + _, err := f.WriteString("hello") + require.NoError(t, err) + _, err = f.Seek(0, 0) + require.NoError(t, err) + buf := make([]byte, 5) + n, err := f.Read(buf) + assert.NoError(t, err) + assert.Equal(t, "hello", string(buf[:n])) + }, + }, + { + name: "creates lock file if it does not exist", + setup: func(t *testing.T) string { + dir := filepath.Join(t.TempDir(), "subdir") + require.NoError(t, os.MkdirAll(dir, 0o755)) + return filepath.Join(dir, "new.lock") + }, + verify: func(t *testing.T, f *os.File) { + t.Helper() + _, err := os.Stat(f.Name()) + assert.NoError(t, err) + }, + }, + { + name: "second lock on same path returns ErrLocked", + setup: func(t *testing.T) string { + lockPath := filepath.Join(t.TempDir(), "contended.lock") + _, unlock, err := flock.TryLock(lockPath) + require.NoError(t, err) + t.Cleanup(unlock) + return lockPath + }, + wantErr: flock.ErrLocked, + }, + { + name: "lock succeeds after unlock", + setup: func(t *testing.T) string { + lockPath := filepath.Join(t.TempDir(), "reuse.lock") + _, unlock, err := flock.TryLock(lockPath) + require.NoError(t, err) + unlock() + return lockPath + }, + }, + { + name: "fails on non-existent directory", + setup: func(t *testing.T) string { + return filepath.Join(t.TempDir(), "no", "such", "dir", "test.lock") + }, + wantErr: os.ErrNotExist, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lockPath := tt.setup(t) + + f, unlock, err := flock.TryLock(lockPath) + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + return + } + + require.NoError(t, err) + require.NotNil(t, f) + defer unlock() + + if tt.verify != nil { + tt.verify(t, f) + } + }) + } +} diff --git a/internal/flock/flock_unix.go b/internal/flock/flock_unix.go new file mode 100644 index 000000000..73f8b1557 --- /dev/null +++ b/internal/flock/flock_unix.go @@ -0,0 +1,32 @@ +//go:build !windows + +package flock + +import ( + "errors" + "os" + "syscall" +) + +// TryLock attempts to acquire an exclusive, non-blocking flock on the given path. +// Returns the locked file and an unlock function on success. The caller should +// read/write through the returned file to avoid platform differences with +// mandatory locking on Windows. +// Returns ErrLocked if the file is already locked by another process. +func TryLock(path string) (f *os.File, unlock func(), err error) { + f, err = os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644) + if err != nil { + return nil, nil, err + } + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + _ = f.Close() + if errors.Is(err, syscall.EWOULDBLOCK) { + return nil, nil, ErrLocked + } + return nil, nil, err + } + return f, func() { + _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + _ = f.Close() + }, nil +} diff --git a/internal/flock/flock_windows.go b/internal/flock/flock_windows.go new file mode 100644 index 000000000..4795af083 --- /dev/null +++ b/internal/flock/flock_windows.go @@ -0,0 +1,41 @@ +//go:build windows + +package flock + +import ( + "errors" + "os" + + "golang.org/x/sys/windows" +) + +// TryLock attempts to acquire an exclusive, non-blocking lock on the given path. +// Returns the locked file and an unlock function on success. The caller should +// read/write through the returned file to avoid Windows mandatory lock conflicts. +// Returns ErrLocked if the file is already locked by another process. +func TryLock(path string) (f *os.File, unlock func(), err error) { + f, err = os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644) + if err != nil { + return nil, nil, err + } + ol := new(windows.Overlapped) + handle := windows.Handle(f.Fd()) + err = windows.LockFileEx( + handle, + windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, + 0, + 1, 0, + ol, + ) + if err != nil { + _ = f.Close() + if errors.Is(err, windows.ERROR_LOCK_VIOLATION) { + return nil, nil, ErrLocked + } + return nil, nil, err + } + return f, func() { + _ = windows.UnlockFileEx(handle, 0, 1, 0, ol) + _ = f.Close() + }, nil +} diff --git a/internal/skills/discovery/collisions.go b/internal/skills/discovery/collisions.go new file mode 100644 index 000000000..38bf9b26b --- /dev/null +++ b/internal/skills/discovery/collisions.go @@ -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 ") +} diff --git a/internal/skills/discovery/collisions_test.go b/internal/skills/discovery/collisions_test.go new file mode 100644 index 000000000..fff5199ba --- /dev/null +++ b/internal/skills/discovery/collisions_test.go @@ -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)) + }) + } +} diff --git a/internal/skills/discovery/discovery.go b/internal/skills/discovery/discovery.go new file mode 100644 index 000000000..93de67faa --- /dev/null +++ b/internal/skills/discovery/discovery.go @@ -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) +} diff --git a/internal/skills/discovery/discovery_test.go b/internal/skills/discovery/discovery_test.go new file mode 100644 index 000000000..7f74c2998 --- /dev/null +++ b/internal/skills/discovery/discovery_test.go @@ -0,0 +1,1157 @@ +package discovery + +import ( + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInstallName(t *testing.T) { + tests := []struct { + name string + skill Skill + wantName string + }{ + { + name: "plain skill", + skill: Skill{Name: "code-review"}, + wantName: "code-review", + }, + { + name: "namespaced skill", + skill: Skill{Name: "issue-triage", Namespace: "monalisa"}, + wantName: "monalisa/issue-triage", + }, + { + name: "plugin skill with namespace", + skill: Skill{Name: "pr-summary", Namespace: "hubot", Convention: "plugins"}, + wantName: "hubot/pr-summary", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.wantName, tt.skill.InstallName()) + }) + } +} + +func TestMatchSkillConventions(t *testing.T) { + tests := []struct { + name string + path string + wantNil bool + wantName string + wantNamespace string + wantConvention string + }{ + { + name: "plugin namespace", + path: "plugins/hubot/skills/pr-summary/SKILL.md", + wantName: "pr-summary", + wantNamespace: "hubot", + wantConvention: "plugins", + }, + { + name: "namespaced skill", + path: "skills/monalisa/issue-triage/SKILL.md", + wantName: "issue-triage", + wantNamespace: "monalisa", + wantConvention: "skills-namespaced", + }, + { + name: "regular skill", + path: "skills/code-review/SKILL.md", + wantName: "code-review", + wantConvention: "skills", + }, + { + name: "non-SKILL.md file", + path: "skills/code-review/README.md", + wantNil: true, + }, + { + name: "plugin skill from different author", + path: "plugins/monalisa/skills/code-review/SKILL.md", + wantName: "code-review", + wantNamespace: "monalisa", + wantConvention: "plugins", + }, + { + name: "root convention single-skill repo", + path: "code-review/SKILL.md", + wantName: "code-review", + wantConvention: "root", + }, + { + name: "root convention excludes skills dir", + path: "skills/SKILL.md", + wantNil: true, + }, + { + name: "root convention excludes dot-prefixed", + path: ".hidden/SKILL.md", + wantNil: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := matchSkillConventions(treeEntry{Path: tt.path, Type: "blob"}) + if tt.wantNil { + assert.Nil(t, m) + return + } + require.NotNil(t, m) + assert.Equal(t, tt.wantName, m.name) + assert.Equal(t, tt.wantNamespace, m.namespace) + assert.Equal(t, tt.wantConvention, m.convention) + }) + } +} + +func TestValidateName(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + {name: "empty", input: "", want: false}, + {name: "too long", input: strings.Repeat("a", 65), want: false}, + {name: "max length is valid", input: strings.Repeat("a", 64), want: true}, + {name: "contains slash", input: "foo/bar", want: false}, + {name: "contains dotdot", input: "foo..bar", want: false}, + {name: "starts with dot", input: ".hidden", want: false}, + {name: "simple name", input: "code-review", want: true}, + {name: "with dots and underscores", input: "octocat_helper.v2", want: true}, + {name: "uppercase allowed", input: "Octocat", want: true}, + {name: "single char", input: "a", want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, validateName(tt.input)) + }) + } +} + +func TestIsSpecCompliant(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + {name: "empty", input: "", want: false}, + {name: "consecutive hyphens", input: "code--review", want: false}, + {name: "uppercase rejected", input: "Octocat", want: false}, + {name: "starts with hyphen", input: "-octocat", want: false}, + {name: "ends with hyphen", input: "octocat-", want: false}, + {name: "valid lowercase with hyphens", input: "issue-triage", want: true}, + {name: "valid single char", input: "a", want: true}, + {name: "valid with numbers", input: "copilot4", want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, IsSpecCompliant(tt.input)) + }) + } +} + +func TestIsFullyQualifiedRef(t *testing.T) { + tests := []struct { + name string + ref string + want bool + }{ + {name: "branch ref", ref: "refs/heads/main", want: true}, + {name: "tag ref", ref: "refs/tags/v1.0", want: true}, + {name: "short branch name", ref: "main", want: false}, + {name: "short tag name", ref: "v1.0", want: false}, + {name: "bare SHA", ref: "abc123def456", want: false}, + {name: "empty", ref: "", want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, IsFullyQualifiedRef(tt.ref)) + }) + } +} + +func TestShortRef(t *testing.T) { + tests := []struct { + name string + ref string + want string + }{ + {name: "branch ref", ref: "refs/heads/main", want: "main"}, + {name: "tag ref", ref: "refs/tags/v1.0", want: "v1.0"}, + {name: "short name passthrough", ref: "main", want: "main"}, + {name: "bare SHA passthrough", ref: "abc123", want: "abc123"}, + {name: "empty passthrough", ref: "", want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, ShortRef(tt.ref)) + }) + } +} + +func TestResolveRef(t *testing.T) { + tests := []struct { + name string + version string + stubs func(*httpmock.Registry) + wantRef string + wantSHA string + wantErr string + }{ + { + name: "short name resolves as branch first", + version: "main", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/main"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "branch-sha"}, + })) + }, + wantRef: "refs/heads/main", + wantSHA: "branch-sha", + }, + { + name: "short name falls back to tag when branch not found", + version: "v1.0", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/v1.0"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v1.0"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "abc123", "type": "commit"}, + })) + }, + wantRef: "refs/tags/v1.0", + wantSHA: "abc123", + }, + { + name: "short name resolves annotated tag", + version: "v2.0", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/v2.0"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v2.0"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "tag-obj-sha", "type": "tag"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/tags/tag-obj-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "real-commit-sha"}, + })) + }, + wantRef: "refs/tags/v2.0", + wantSHA: "real-commit-sha", + }, + { + name: "short name falls back to commit SHA", + version: "deadbeef", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/deadbeef"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/deadbeef"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/commits/deadbeef"), + httpmock.JSONResponse(map[string]interface{}{"sha": "deadbeef"})) + }, + wantRef: "deadbeef", + wantSHA: "deadbeef", + }, + { + name: "short name not found anywhere", + version: "nonexistent", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/nonexistent"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/nonexistent"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/commits/nonexistent"), + httpmock.StatusStringResponse(404, "not found")) + }, + wantErr: `ref "nonexistent" not found as branch, tag, or commit in monalisa/octocat-skills`, + }, + { + name: "branch wins over tag with same short name", + version: "release", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/release"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "branch-sha"}, + })) + // tag stub is not registered because branch succeeds first + }, + wantRef: "refs/heads/release", + wantSHA: "branch-sha", + }, + { + name: "fully qualified tag ref resolved directly", + version: "refs/tags/v1.0", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v1.0"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "tag-sha", "type": "commit"}, + })) + }, + wantRef: "refs/tags/v1.0", + wantSHA: "tag-sha", + }, + { + name: "fully qualified branch ref resolved directly", + version: "refs/heads/feature", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/feature"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "feature-sha"}, + })) + }, + wantRef: "refs/heads/feature", + wantSHA: "feature-sha", + }, + { + name: "fully qualified tag ref not found", + version: "refs/tags/nonexistent", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/nonexistent"), + httpmock.StatusStringResponse(404, "not found")) + }, + wantErr: `tag "nonexistent" not found in monalisa/octocat-skills`, + }, + { + name: "fully qualified branch ref not found", + version: "refs/heads/nonexistent", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/nonexistent"), + httpmock.StatusStringResponse(404, "not found")) + }, + wantErr: `branch "nonexistent" not found in monalisa/octocat-skills`, + }, + { + name: "no version uses latest release with fully qualified ref", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.JSONResponse(map[string]interface{}{"tag_name": "v3.0"})) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "release-sha", "type": "commit"}, + })) + }, + wantRef: "refs/tags/v3.0", + wantSHA: "release-sha", + }, + { + name: "no version falls back to default branch with fully qualified ref", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"})) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/main"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "branch-sha"}, + })) + }, + wantRef: "refs/heads/main", + wantSHA: "branch-sha", + }, + { + name: "annotated tag dereference failure", + version: "refs/tags/v4.0", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v4.0"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "tag-obj-sha", "type": "tag"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/tags/tag-obj-sha"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantErr: "could not dereference annotated tag", + }, + { + name: "no version with server error does not fall back to default branch", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StatusStringResponse(500, "internal server error")) + }, + wantErr: "could not fetch latest release", + }, + { + name: "no version with forbidden error does not fall back to default branch", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StatusStringResponse(403, "forbidden")) + }, + wantErr: "could not fetch latest release", + }, + { + name: "empty tag_name in latest release falls back to default branch", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.JSONResponse(map[string]interface{}{"tag_name": ""})) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"})) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/main"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "fallback-sha"}, + })) + }, + wantRef: "refs/heads/main", + wantSHA: "fallback-sha", + }, + { + name: "empty default_branch returns error", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": ""})) + }, + wantErr: "could not determine default branch", + }, + { + name: "short name with server error on branch lookup does not fall through", + version: "main", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/main"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantErr: `branch "main" not found in monalisa/octocat-skills`, + }, + { + name: "short name with forbidden error on branch lookup does not fall through", + version: "develop", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/develop"), + httpmock.StatusStringResponse(403, "forbidden")) + }, + wantErr: `branch "develop" not found in monalisa/octocat-skills`, + }, + { + name: "short name with server error on tag lookup does not fall through", + version: "v5.0", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/v5.0"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v5.0"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantErr: `tag "v5.0" not found in monalisa/octocat-skills`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + ref, err := ResolveRef(client, "github.com", "monalisa", "octocat-skills", tt.version) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantRef, ref.Ref) + assert.Equal(t, tt.wantSHA, ref.SHA) + }) + } +} + +func TestFetchBlob(t *testing.T) { + tests := []struct { + name string + stubs func(*httpmock.Registry) + wantErr string + want string + }{ + { + name: "decodes base64 content", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/abc"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc", "encoding": "base64", "content": "SGVsbG8gV29ybGQ=", + })) + }, + want: "Hello World", + }, + { + name: "rejects non-base64 encoding", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/abc"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc", "encoding": "utf-8", "content": "raw", + })) + }, + wantErr: "unexpected blob encoding: utf-8", + }, + { + name: "API error", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/abc"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantErr: "could not fetch blob", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + got, err := FetchBlob(client, "github.com", "monalisa", "octocat-skills", "abc") + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestDiscoverSkills(t *testing.T) { + tests := []struct { + name string + stubs func(*httpmock.Registry) + wantSkills []string + wantErr string + }{ + { + name: "discovers skills from tree", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "skills/code-review", "type": "tree", "sha": "tree-sha-1"}, + {"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "blob-1"}, + {"path": "skills/issue-triage", "type": "tree", "sha": "tree-sha-2"}, + {"path": "skills/issue-triage/SKILL.md", "type": "blob", "sha": "blob-2"}, + {"path": "README.md", "type": "blob", "sha": "readme"}, + }, + })) + }, + wantSkills: []string{"code-review", "issue-triage"}, + }, + { + name: "truncated tree returns error", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc123", "truncated": true, "tree": []map[string]interface{}{}, + })) + }, + wantErr: "too large", + }, + { + name: "no skills found", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "README.md", "type": "blob", "sha": "readme"}, + }, + })) + }, + wantErr: "no skills found", + }, + { + name: "API error", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantErr: "could not fetch repository tree", + }, + { + name: "deduplicates skills from same directory", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "skills/code-review", "type": "tree", "sha": "tree-sha"}, + {"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "blob-1"}, + {"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "blob-2"}, + }, + })) + }, + wantSkills: []string{"code-review"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + skills, err := DiscoverSkills(client, "github.com", "monalisa", "octocat-skills", "abc123") + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + var names []string + for _, s := range skills { + names = append(names, s.Name) + } + assert.Equal(t, tt.wantSkills, names) + }) + } +} + +func TestDiscoverSkillByPath(t *testing.T) { + tests := []struct { + name string + skillPath string + stubs func(*httpmock.Registry) + wantName string + wantNS string + wantErr string + }{ + { + name: "discovers skill by path", + skillPath: "skills/code-review", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/skills"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "code-review", "path": "skills/code-review", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "blob-sha"}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "blob-sha", "encoding": "base64", "content": "IyBTa2lsbA==", + })) + }, + wantName: "code-review", + }, + { + name: "namespaced path sets namespace", + skillPath: "skills/monalisa/issue-triage", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/skills%2Fmonalisa"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "issue-triage", "path": "skills/monalisa/issue-triage", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "blob-sha"}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "blob-sha", "encoding": "base64", "content": "IyBTa2lsbA==", + })) + }, + wantName: "issue-triage", + wantNS: "monalisa", + }, + { + name: "parent path with spaces is URL encoded", + skillPath: "my skills/code-review", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/my%20skills"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "code-review", "path": "my skills/code-review", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "blob-sha"}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "blob-sha", "encoding": "base64", "content": "IyBTa2lsbA==", + })) + }, + wantName: "code-review", + }, + { + name: "strips trailing SKILL.md from path", + skillPath: "skills/code-review/SKILL.md", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/skills"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "code-review", "path": "skills/code-review", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "blob-sha"}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "blob-sha", "encoding": "base64", "content": "IyBTa2lsbA==", + })) + }, + wantName: "code-review", + }, + { + name: "invalid skill name", + skillPath: "skills/.hidden-skill", + wantErr: "invalid skill name", + }, + { + name: "skill directory not found", + skillPath: "skills/nonexistent", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/skills"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "other-skill", "path": "skills/other-skill", "sha": "tree-sha", "type": "dir"}, + })) + }, + wantErr: "skill directory", + }, + { + name: "no SKILL.md in directory", + skillPath: "skills/code-review", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/skills"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "code-review", "path": "skills/code-review", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "README.md", "type": "blob", "sha": "readme"}, + }, + })) + }, + wantErr: "no SKILL.md found", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + if tt.stubs != nil { + tt.stubs(reg) + } + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + skill, err := DiscoverSkillByPath(client, "github.com", "monalisa", "octocat-skills", "abc123", tt.skillPath) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantName, skill.Name) + assert.Equal(t, tt.wantNS, skill.Namespace) + }) + } +} + +func TestDiscoverLocalSkills(t *testing.T) { + tests := []struct { + name string + createDir bool + setup func(t *testing.T, dir string) + wantSkills []string + wantErr string + }{ + { + name: "discovers skills in skills/ directory", + createDir: true, + setup: func(t *testing.T, dir string) { + t.Helper() + for _, name := range []string{"code-review", "issue-triage"} { + skillDir := filepath.Join(dir, "skills", name) + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# "+name), 0o644)) + } + }, + wantSkills: []string{"code-review", "issue-triage"}, + }, + { + name: "single skill at root", + createDir: true, + setup: func(t *testing.T, dir string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: root-skill + --- + # Root + `)), 0o644)) + }, + wantSkills: []string{"root-skill"}, + }, + { + name: "no skills found", + createDir: true, + setup: func(t *testing.T, dir string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("# Not a skill"), 0o644)) + }, + wantErr: "no skills found", + }, + { + name: "nonexistent directory", + setup: func(t *testing.T, dir string) {}, + wantErr: "could not access", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "repo") + if tt.createDir { + require.NoError(t, os.MkdirAll(dir, 0o755)) + } + tt.setup(t, dir) + + skills, err := DiscoverLocalSkills(dir) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + var names []string + for _, s := range skills { + names = append(names, s.Name) + } + assert.ElementsMatch(t, tt.wantSkills, names) + }) + } +} + +func TestMatchesSkillPath(t *testing.T) { + tests := []struct { + name string + path string + wantName string + }{ + {name: "skills convention", path: "skills/code-review/SKILL.md", wantName: "code-review"}, + {name: "namespaced convention", path: "skills/monalisa/issue-triage/SKILL.md", wantName: "issue-triage"}, + {name: "plugins convention", path: "plugins/hubot/skills/pr-summary/SKILL.md", wantName: "pr-summary"}, + {name: "non-skill file", path: "README.md", wantName: ""}, + {name: "non-SKILL.md in skill dir", path: "skills/code-review/prompt.txt", wantName: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.wantName, MatchesSkillPath(tt.path)) + }) + } +} + +func TestMatchSkillPath(t *testing.T) { + tests := []struct { + testName string + path string + wantName string + wantNamespace string + }{ + {testName: "skills convention", path: "skills/code-review/SKILL.md", wantName: "code-review", wantNamespace: ""}, + {testName: "namespaced convention", path: "skills/monalisa/issue-triage/SKILL.md", wantName: "issue-triage", wantNamespace: "monalisa"}, + {testName: "plugins convention", path: "plugins/hubot/skills/pr-summary/SKILL.md", wantName: "pr-summary", wantNamespace: "hubot"}, + {testName: "non-skill file", path: "README.md", wantName: "", wantNamespace: ""}, + {testName: "same name different namespace 1", path: "skills/kynan/commit/SKILL.md", wantName: "commit", wantNamespace: "kynan"}, + {testName: "same name different namespace 2", path: "skills/will/commit/SKILL.md", wantName: "commit", wantNamespace: "will"}, + {testName: "root convention", path: "my-skill/SKILL.md", wantName: "my-skill", wantNamespace: ""}, + } + for _, tt := range tests { + t.Run(tt.testName, func(t *testing.T) { + name, namespace := MatchSkillPath(tt.path) + assert.Equal(t, tt.wantName, name) + assert.Equal(t, tt.wantNamespace, namespace) + }) + } +} + +func TestDiscoverSkillFiles(t *testing.T) { + tests := []struct { + name string + stubs func(*httpmock.Registry) + wantPaths []string + wantErr string + }{ + { + name: "returns files with skill path prefix", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "sha1", "size": 10}, + {"path": "scripts/setup.sh", "type": "blob", "sha": "sha2", "size": 50}, + {"path": "scripts", "type": "tree", "sha": "treesub"}, + }, + })) + }, + wantPaths: []string{"skills/code-review/SKILL.md", "skills/code-review/scripts/setup.sh"}, + }, + { + name: "truncated tree falls back to walkTree", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", "truncated": true, "tree": []map[string]interface{}{}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "sha1", "size": 10}, + }, + })) + }, + wantPaths: []string{"skills/code-review/SKILL.md"}, + }, + { + name: "API error", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantErr: "could not fetch skill tree", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + files, err := DiscoverSkillFiles(client, "github.com", "monalisa", "octocat-skills", "tree123", "skills/code-review") + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + var paths []string + for _, f := range files { + paths = append(paths, f.Path) + } + assert.Equal(t, tt.wantPaths, paths) + }) + } +} + +func TestListSkillFiles(t *testing.T) { + tests := []struct { + name string + stubs func(*httpmock.Registry) + wantPaths []string + wantErr string + }{ + { + name: "returns relative paths", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "sha1", "size": 10}, + {"path": "prompt.txt", "type": "blob", "sha": "sha2", "size": 20}, + }, + })) + }, + wantPaths: []string{"SKILL.md", "prompt.txt"}, + }, + { + name: "truncated tree falls back to walkTree with nested subtree", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", "truncated": true, "tree": []map[string]interface{}{}, + })) + // walkTree fetches the top-level tree non-recursively + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "sha1", "size": 10}, + {"path": "scripts", "type": "tree", "sha": "subtree1"}, + }, + })) + // walkTree recurses into the "scripts" subtree + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/subtree1"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "subtree1", + "tree": []map[string]interface{}{ + {"path": "setup.sh", "type": "blob", "sha": "sha2", "size": 50}, + }, + })) + }, + wantPaths: []string{"SKILL.md", "scripts/setup.sh"}, + }, + { + name: "API error", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantErr: "could not fetch skill tree", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + files, err := ListSkillFiles(client, "github.com", "monalisa", "octocat-skills", "tree123") + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + var paths []string + for _, f := range files { + paths = append(paths, f.Path) + } + assert.Equal(t, tt.wantPaths, paths) + }) + } +} + +func TestFetchDescriptionsConcurrent(t *testing.T) { + tests := []struct { + name string + skills []Skill + stubs func(*httpmock.Registry) + wantDescs []string + }{ + { + name: "fetches descriptions for skills without one", + skills: []Skill{ + {Name: "code-review", BlobSHA: "blob1"}, + {Name: "issue-triage", Description: "already set"}, + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob1"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "blob1", "encoding": "base64", + "content": "LS0tCm5hbWU6IGNvZGUtcmV2aWV3CmRlc2NyaXB0aW9uOiBSZXZpZXdzIFBScwotLS0KIyBUZXN0", + })) + }, + wantDescs: []string{"Reviews PRs", "already set"}, + }, + { + name: "no-op when all descriptions set", + skills: []Skill{ + {Name: "code-review", Description: "set"}, + }, + stubs: func(reg *httpmock.Registry) {}, + wantDescs: []string{"set"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + FetchDescriptionsConcurrent(client, "github.com", "monalisa", "octocat-skills", tt.skills, nil) + var descs []string + for _, s := range tt.skills { + descs = append(descs, s.Description) + } + assert.Equal(t, tt.wantDescs, descs) + }) + } +} diff --git a/internal/skills/frontmatter/frontmatter.go b/internal/skills/frontmatter/frontmatter.go new file mode 100644 index 000000000..87ad067a0 --- /dev/null +++ b/internal/skills/frontmatter/frontmatter.go @@ -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 +} diff --git a/internal/skills/frontmatter/frontmatter_test.go b/internal/skills/frontmatter/frontmatter_test.go new file mode 100644 index 000000000..d88811ea2 --- /dev/null +++ b/internal/skills/frontmatter/frontmatter_test.go @@ -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) + } + }) + } +} diff --git a/internal/skills/installer/installer.go b/internal/skills/installer/installer.go new file mode 100644 index 000000000..e27d35f5b --- /dev/null +++ b/internal/skills/installer/installer.go @@ -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 +} diff --git a/internal/skills/installer/installer_test.go b/internal/skills/installer/installer_test.go new file mode 100644 index 000000000..e05a3541e --- /dev/null +++ b/internal/skills/installer/installer_test.go @@ -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") + } + }) + } +} diff --git a/internal/skills/lockfile/lockfile.go b/internal/skills/lockfile/lockfile.go new file mode 100644 index 000000000..42d2abb34 --- /dev/null +++ b/internal/skills/lockfile/lockfile.go @@ -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) +} diff --git a/internal/skills/lockfile/lockfile_test.go b/internal/skills/lockfile/lockfile_test.go new file mode 100644 index 000000000..d68e9a8f1 --- /dev/null +++ b/internal/skills/lockfile/lockfile_test.go @@ -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 +} diff --git a/internal/skills/registry/registry.go b/internal/skills/registry/registry.go new file mode 100644 index 000000000..b112d361a --- /dev/null +++ b/internal/skills/registry/registry.go @@ -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) +} diff --git a/internal/skills/registry/registry_test.go b/internal/skills/registry/registry_test.go new file mode 100644 index 000000000..003a28afa --- /dev/null +++ b/internal/skills/registry/registry_test.go @@ -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) + } + }) + } +} diff --git a/internal/skills/source/source.go b/internal/skills/source/source.go new file mode 100644 index 000000000..5e8f52888 --- /dev/null +++ b/internal/skills/source/source.go @@ -0,0 +1,66 @@ +package source + +import ( + "fmt" + "strings" + + "github.com/cli/cli/v2/internal/ghrepo" +) + +const SupportedHost = "github.com" + +// BuildRepoURL returns the canonical repository URL stored in skill metadata. +func BuildRepoURL(host, owner, repo string) string { + return ghrepo.GenerateRepoURL(ghrepo.NewWithHost(owner, repo, host), "") +} + +// ParseRepoURL parses a repository URL stored in skill metadata. +func ParseRepoURL(raw string) (ghrepo.Interface, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("repository URL is empty") + } + + repo, err := ghrepo.FromFullName(raw) + if err != nil { + return nil, fmt.Errorf("invalid repository URL %q: %w", raw, err) + } + + return repo, nil +} + +// ParseMetadataRepo extracts repository information from skill metadata. +func ParseMetadataRepo(meta map[string]interface{}) (ghrepo.Interface, bool, error) { + if meta == nil { + return nil, false, nil + } + + repoValue, _ := meta["github-repo"].(string) + if repoValue == "" { + return nil, false, nil + } + + repo, err := ParseRepoURL(repoValue) + if err != nil { + return nil, true, err + } + + return repo, true, nil +} + +// ValidateSupportedHost rejects hosts that are not supported in public preview. +func ValidateSupportedHost(host string) error { + host = normalizeHost(host) + if host == "" { + return fmt.Errorf("could not determine repository host") + } + if host != SupportedHost { + return fmt.Errorf("GitHub Skills currently supports only %s as a host; got %s", SupportedHost, host) + } + return nil +} + +func normalizeHost(host string) string { + host = strings.TrimSpace(strings.ToLower(host)) + return strings.TrimPrefix(host, "www.") +} diff --git a/internal/skills/source/source_test.go b/internal/skills/source/source_test.go new file mode 100644 index 000000000..f797591b4 --- /dev/null +++ b/internal/skills/source/source_test.go @@ -0,0 +1,76 @@ +package source + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildRepoURL(t *testing.T) { + assert.Equal(t, "https://github.com/monalisa/octocat-skills", BuildRepoURL("github.com", "monalisa", "octocat-skills")) +} + +func TestParseMetadataRepo(t *testing.T) { + tests := []struct { + name string + meta map[string]interface{} + wantOwner string + wantRepo string + wantHost string + wantFound bool + wantErr string + }{ + { + name: "parses repo url metadata", + meta: map[string]interface{}{ + "github-repo": "https://github.com/monalisa/octocat-skills", + }, + wantOwner: "monalisa", + wantRepo: "octocat-skills", + wantHost: SupportedHost, + wantFound: true, + }, + { + name: "invalid repo url", + meta: map[string]interface{}{ + "github-repo": "not a url", + }, + wantFound: true, + wantErr: "invalid repository URL", + }, + { + name: "missing repo metadata", + meta: map[string]interface{}{}, + wantFound: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo, found, err := ParseMetadataRepo(tt.meta) + assert.Equal(t, tt.wantFound, found) + if !tt.wantFound { + require.NoError(t, err) + assert.Nil(t, repo) + return + } + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + require.NotNil(t, repo) + assert.Equal(t, tt.wantOwner, repo.RepoOwner()) + assert.Equal(t, tt.wantRepo, repo.RepoName()) + assert.Equal(t, tt.wantHost, repo.RepoHost()) + }) + } +} + +func TestValidateSupportedHost(t *testing.T) { + require.NoError(t, ValidateSupportedHost("github.com")) + require.ErrorContains(t, ValidateSupportedHost("acme.ghes.com"), "supports only github.com") +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index ed33f568e..d44ad840c 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -38,6 +38,7 @@ import ( runCmd "github.com/cli/cli/v2/pkg/cmd/run" searchCmd "github.com/cli/cli/v2/pkg/cmd/search" secretCmd "github.com/cli/cli/v2/pkg/cmd/secret" + skillsCmd "github.com/cli/cli/v2/pkg/cmd/skills" sshKeyCmd "github.com/cli/cli/v2/pkg/cmd/ssh-key" statusCmd "github.com/cli/cli/v2/pkg/cmd/status" variableCmd "github.com/cli/cli/v2/pkg/cmd/variable" @@ -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)) diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go new file mode 100644 index 000000000..1b6a7fd8f --- /dev/null +++ b/pkg/cmd/skills/install/install.go @@ -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 [] [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/\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) +} diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go new file mode 100644 index 000000000..481227524 --- /dev/null +++ b/pkg/cmd/skills/install/install_test.go @@ -0,0 +1,1982 @@ +package install + +import ( + "bytes" + "encoding/base64" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/context" + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/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 TestNewCmdInstall(t *testing.T) { + tests := []struct { + name string + cli string + wantOpts InstallOptions + wantLocalPath bool + wantErr bool + }{ + { + name: "repo argument only", + cli: "monalisa/skills-repo", + wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", Scope: "project"}, + }, + { + name: "repo and skill", + cli: "monalisa/skills-repo git-commit", + wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Scope: "project"}, + }, + { + name: "all flags", + cli: "monalisa/skills-repo git-commit --agent github-copilot --scope user --pin v1.0.0 --force", + wantOpts: InstallOptions{ + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "user", + Pin: "v1.0.0", + Force: true, + }, + }, + { + name: "dir flag", + cli: "monalisa/skills-repo git-commit --dir ./custom-skills", + wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Dir: "./custom-skills", Scope: "project"}, + }, + { + name: "too many args", + cli: "a b c", + wantErr: true, + }, + { + name: "invalid agent flag", + cli: "monalisa/skills-repo git-commit --agent nonexistent", + wantErr: true, + }, + { + name: "pin conflicts with inline version", + cli: "monalisa/skills-repo git-commit@v1.0.0 --pin v2.0.0", + wantErr: true, + }, + { + name: "alias add works", + cli: "monalisa/skills-repo git-commit", + wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Scope: "project"}, + }, + { + name: "from-local flag sets localPath", + cli: "--from-local ./local-dir", + wantOpts: InstallOptions{SkillSource: "./local-dir", Scope: "project", FromLocal: true}, + wantLocalPath: true, + }, + { + name: "from-local with absolute path", + cli: "--from-local /absolute/path", + wantOpts: InstallOptions{SkillSource: "/absolute/path", Scope: "project", FromLocal: true}, + wantLocalPath: true, + }, + { + name: "from-local with tilde path", + cli: "--from-local ~/skills", + wantOpts: InstallOptions{SkillSource: "~/skills", Scope: "project", FromLocal: true}, + wantLocalPath: true, + }, + { + name: "owner/repo does not set localPath", + cli: "monalisa/skills-repo", + wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", Scope: "project"}, + }, + { + name: "local-looking path without --from-local treated as repo", + cli: "./local-dir", + wantOpts: InstallOptions{SkillSource: "./local-dir", Scope: "project"}, + }, + { + name: "from-local without argument errors", + cli: "--from-local", + wantErr: true, + }, + { + name: "from-local with --pin is mutually exclusive", + cli: "--from-local ./local-dir --pin v1.0.0", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{}, + } + + var gotOpts *InstallOptions + cmd := NewCmdInstall(f, func(opts *InstallOptions) error { + gotOpts = opts + return nil + }) + + args, err := shlex.Split(tt.cli) + require.NoError(t, err) + cmd.SetArgs(args) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err = cmd.Execute() + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.NotNil(t, gotOpts) + assert.Equal(t, tt.wantOpts.SkillSource, gotOpts.SkillSource) + assert.Equal(t, tt.wantOpts.SkillName, gotOpts.SkillName) + assert.Equal(t, tt.wantOpts.Agent, gotOpts.Agent) + assert.Equal(t, tt.wantOpts.Scope, gotOpts.Scope) + assert.Equal(t, tt.wantOpts.Pin, gotOpts.Pin) + assert.Equal(t, tt.wantOpts.Dir, gotOpts.Dir) + assert.Equal(t, tt.wantOpts.Force, gotOpts.Force) + assert.Equal(t, tt.wantOpts.FromLocal, gotOpts.FromLocal) + if tt.wantLocalPath { + assert.NotEmpty(t, gotOpts.localPath, "expected localPath to be set") + } else { + assert.Empty(t, gotOpts.localPath, "expected localPath to be empty") + } + }) + } + + // Verify command metadata separately. + t.Run("command metadata", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} + cmd := NewCmdInstall(f, nil) + + assert.Equal(t, "install [] [flags]", cmd.Use) + assert.NotEmpty(t, cmd.Short) + assert.NotEmpty(t, cmd.Long) + assert.NotEmpty(t, cmd.Example) + assert.Contains(t, cmd.Aliases, "add") + + for _, flag := range []string{"agent", "scope", "pin", "dir", "force"} { + assert.NotNil(t, cmd.Flags().Lookup(flag), "missing flag: --%s", flag) + } + }) +} + +// --- HTTP stub helpers --- + +// stubResolveVersion registers API stubs for latest release + tag resolution. +func stubResolveVersion(reg *httpmock.Registry, owner, repo, tag, sha string) { + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/releases/latest", owner, repo)), + httpmock.StringResponse(fmt.Sprintf(`{"tag_name": %q}`, tag)), + ) + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/git/ref/tags/%s", owner, repo, tag)), + httpmock.StringResponse(fmt.Sprintf(`{"object": {"sha": %q, "type": "commit"}}`, sha)), + ) +} + +// stubDiscoverTree registers the single recursive-tree call used by DiscoverSkills. +func stubDiscoverTree(reg *httpmock.Registry, owner, repo, sha, treeJSON string) { + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/git/trees/%s", owner, repo, sha)), + httpmock.StringResponse(fmt.Sprintf(`{"sha": %q, "tree": [%s]}`, sha, treeJSON)), + ) +} + +// stubInstallFiles registers subtree + blob stubs for installer.Install (one skill). +func stubInstallFiles(reg *httpmock.Registry, owner, repo, treeSHA, blobSHA, content string) { + encoded := base64.StdEncoding.EncodeToString([]byte(content)) + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/git/trees/%s", owner, repo, treeSHA)), + httpmock.StringResponse(fmt.Sprintf(`{"tree": [{"path": "SKILL.md", "type": "blob", "sha": %q, "size": 50}]}`, blobSHA)), + ) + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/git/blobs/%s", owner, repo, blobSHA)), + httpmock.StringResponse(fmt.Sprintf(`{"sha": %q, "content": %q, "encoding": "base64"}`, blobSHA, encoded)), + ) +} + +// stubSkillByPath registers stubs for DiscoverSkillByPath (contents API + tree). +func stubSkillByPath(reg *httpmock.Registry, owner, repo, sha, skillPath, skillName, treeSHA string) { + parentPath := skillPath + if idx := strings.LastIndex(skillPath, "/"); idx >= 0 { + parentPath = skillPath[:idx] + } + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/contents/%s", owner, repo, parentPath)), + httpmock.StringResponse(fmt.Sprintf(`[{"name": %q, "path": %q, "sha": %q, "type": "dir"}]`, skillName, skillPath, treeSHA)), + ) +} + +// writeLocalTestSkill creates a skill directory with a SKILL.md file. +func writeLocalTestSkill(t *testing.T, baseDir, subPath, content string) { + t.Helper() + skillDir := filepath.Join(baseDir, subPath) + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) +} + +// --- Skill content constants --- + +var gitCommitContent = heredoc.Doc(` + --- + name: git-commit + description: Writes commits + --- + # Git Commit +`) + +// singleSkillTreeJSON returns tree entries for a single skill with the given name. +func singleSkillTreeJSON(name, treeSHA, blobSHA string) string { + return fmt.Sprintf( + `{"path": "skills/%s", "type": "tree", "sha": %q}, {"path": "skills/%s/SKILL.md", "type": "blob", "sha": %q}`, + name, treeSHA, name, blobSHA, + ) +} + +func TestInstallRun(t *testing.T) { + tests := []struct { + name string + isTTY bool + setup func(t *testing.T) + stubs func(*httpmock.Registry) + opts func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions + verify func(t *testing.T) + wantErr string + wantStdout string + wantStderr string + }{ + { + name: "non-interactive without repo errors", + isTTY: false, + opts: func(ios *iostreams.IOStreams, _ *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantErr: "must specify a repository to install from", + }, + { + name: "non-interactive without skill name errors", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + } + }, + wantErr: "must specify a skill name when not running interactively", + }, + { + name: "remote install writes files with tracking metadata", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install with --agent claude-code", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "claude-code", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install defaults to github-copilot non-interactively", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install with --scope user", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "user", + ScopeChanged: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install with --dir bypasses scope resolution", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install with --force overwrites existing skill", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + targetDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "git-commit"), 0o755)) + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + Force: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install existing skill without force non-interactive errors", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + targetDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "git-commit"), 0o755)) + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + } + }, + wantErr: "already installed", + }, + { + name: "remote install skill not found errors", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "nonexistent", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantErr: `skill "nonexistent" not found`, + }, + { + name: "remote install ambiguous skill name errors", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + // Two namespaced skills with the same name + treeJSON := `{"path": "skills/alice", "type": "tree", "sha": "nsA"}, ` + + `{"path": "skills/alice/xlsx-pro", "type": "tree", "sha": "treeA"}, ` + + `{"path": "skills/alice/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobA"}, ` + + `{"path": "skills/bob", "type": "tree", "sha": "nsB"}, ` + + `{"path": "skills/bob/xlsx-pro", "type": "tree", "sha": "treeB"}, ` + + `{"path": "skills/bob/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobB"}` + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "xlsx-pro", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantErr: "ambiguous", + }, + { + name: "remote install namespaced exact match resolves ambiguity", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + treeJSON := `{"path": "skills/alice", "type": "tree", "sha": "nsA"}, ` + + `{"path": "skills/alice/xlsx-pro", "type": "tree", "sha": "treeA"}, ` + + `{"path": "skills/alice/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobA"}, ` + + `{"path": "skills/bob", "type": "tree", "sha": "nsB"}, ` + + `{"path": "skills/bob/xlsx-pro", "type": "tree", "sha": "treeB"}, ` + + `{"path": "skills/bob/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobB"}` + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeB", "blobB", + "---\nname: xlsx-pro\ndescription: Bob version\n---\n# B\n") + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "bob/xlsx-pro", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed bob/xlsx-pro", + }, + { + name: "remote install with invalid repo argument errors", + isTTY: false, + opts: func(ios *iostreams.IOStreams, _ *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "invalid", + SkillName: "git-commit", + } + }, + wantErr: "invalid repository reference", + }, + { + name: "remote install with pin flag resolves version", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/heads/v2.0.0"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/tags/v2.0.0"), + httpmock.StringResponse(`{"object": {"sha": "def456", "type": "commit"}}`), + ) + stubDiscoverTree(reg, "monalisa", "skills-repo", "def456", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Pin: "v2.0.0", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + wantStderr: "v2.0.0", + }, + { + name: "remote install shows pre-install disclaimer", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + wantStderr: "not verified by GitHub", + }, + { + name: "remote install outputs review hint", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + wantStderr: "gh skill preview monalisa/skills-repo git-commit@abc123", + }, + { + name: "remote install outputs file tree for TTY", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStderr: "SKILL.md", + }, + { + name: "remote install with inline version parses name and version", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/heads/v1.2.0"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/tags/v1.2.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit@v1.2.0", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + wantStderr: "v1.2.0", + }, + { + name: "remote install by skill path skips full discovery", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubSkillByPath(reg, "monalisa", "skills-repo", "abc123", "skills/git-commit", "git-commit", "treeSHA") + // DiscoverSkillByPath: tree + blob (for fetchDescription) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + // installer.Install: tree + blob (again, for writing files) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "skills/git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install with URL repo argument", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "https://github.com/monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install all with collisions errors", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + // Two skills with the same install name: skills/xlsx-pro and root xlsx-pro + treeJSON := `{"path": "skills/xlsx-pro", "type": "tree", "sha": "tree0"}, ` + + `{"path": "skills/xlsx-pro/SKILL.md", "type": "blob", "sha": "blob0"}, ` + + `{"path": "xlsx-pro", "type": "tree", "sha": "tree1"}, ` + + `{"path": "xlsx-pro/SKILL.md", "type": "blob", "sha": "blob1"}` + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + pm := &prompter.PrompterMock{ + MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { + return []string{allSkillsKey}, nil + }, + } + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantErr: "conflicting names", + }, + { + name: "remote install all with namespaced skills avoids collisions", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + treeJSON := `{"path": "skills/alice", "type": "tree", "sha": "nsA"}, ` + + `{"path": "skills/alice/xlsx-pro", "type": "tree", "sha": "treeA"}, ` + + `{"path": "skills/alice/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobA"}, ` + + `{"path": "skills/bob", "type": "tree", "sha": "nsB"}, ` + + `{"path": "skills/bob/xlsx-pro", "type": "tree", "sha": "treeB"}, ` + + `{"path": "skills/bob/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobB"}` + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) + // Extra blob stubs consumed by FetchDescriptionsConcurrent during interactive selection. + contentA := base64.StdEncoding.EncodeToString([]byte("---\nname: xlsx-pro\ndescription: Alice\n---\n# A\n")) + contentB := base64.StdEncoding.EncodeToString([]byte("---\nname: xlsx-pro\ndescription: Bob\n---\n# B\n")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blobA"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blobA", "content": %q, "encoding": "base64"}`, contentA))) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blobB"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blobB", "content": %q, "encoding": "base64"}`, contentB))) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeA", "blobA", + "---\nname: xlsx-pro\ndescription: Alice\n---\n# A\n") + stubInstallFiles(reg, "monalisa", "skills-repo", "treeB", "blobB", + "---\nname: xlsx-pro\ndescription: Bob\n---\n# B\n") + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + pm := &prompter.PrompterMock{ + MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { + return []string{allSkillsKey}, nil + }, + } + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed", + }, + { + name: "remote install friendlyDir shows tilde for home paths", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "user", + ScopeChanged: true, + } + }, + wantStdout: "~", + }, + { + name: "interactive skill selection via prompt", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + // 31 skills to exercise maxSearchResults cap + one without description + var treeEntries []string + for i := range 31 { + name := fmt.Sprintf("skill-%02d", i) + treeEntries = append(treeEntries, + fmt.Sprintf(`{"path": "skills/%s", "type": "tree", "sha": "tree-%s"}`, name, name), + fmt.Sprintf(`{"path": "skills/%s/SKILL.md", "type": "blob", "sha": "blob-%s"}`, name, name)) + } + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + strings.Join(treeEntries, ", ")) + // Blob stubs for FetchDescriptionsConcurrent (one per skill) + for i := range 31 { + name := fmt.Sprintf("skill-%02d", i) + blobSHA := fmt.Sprintf("blob-%s", name) + var content string + if i == 0 { + // First skill has no description (exercises else branch in label building) + content = fmt.Sprintf("---\nname: %s\n---\n# Skill\n", name) + } else { + content = fmt.Sprintf("---\nname: %s\ndescription: Does %s things\n---\n# Skill\n", name, name) + } + encoded := base64.StdEncoding.EncodeToString([]byte(content)) + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/monalisa/octocat-skills/git/blobs/%s", blobSHA)), + httpmock.StringResponse(fmt.Sprintf(`{"sha": %q, "content": %q, "encoding": "base64"}`, blobSHA, encoded))) + } + // Install stubs for the selected skill (skill-01) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-skill-01", "blob-skill-01", + "---\nname: skill-01\ndescription: Does skill-01 things\n---\n# Skill\n") + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + pm := &prompter.PrompterMock{ + MultiSelectWithSearchFunc: func(prompt, searchPrompt string, defaults, persistentOptions []string, searchFunc func(string) prompter.MultiSelectSearchResult) ([]string, error) { + // Exercise searchFunc: empty query hits maxSearchResults cap (31 > 30) + all := searchFunc("") + if all.MoreResults == 0 { + return nil, fmt.Errorf("expected MoreResults > 0 for 31 skills") + } + // Non-empty query filters down + filtered := searchFunc("skill-01") + if len(filtered.Keys) == 0 { + return nil, fmt.Errorf("search returned no results") + } + return []string{filtered.Keys[0]}, nil + }, + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil + }, + } + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + Agent: "github-copilot", + Force: true, + } + }, + wantStdout: "Installed skill-01", + }, + { + name: "interactive scope prompt", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + pm := &prompter.PrompterMock{ + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil + }, + } + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Agent: "github-copilot", + Force: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "interactive overwrite confirmation declined", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + destDir := t.TempDir() + writeLocalTestSkill(t, destDir, "git-commit", gitCommitContent) + pm := &prompter.PrompterMock{ + ConfirmFunc: func(prompt string, defaultValue bool) (bool, error) { + return false, nil + }, + } + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: destDir, + } + }, + wantStderr: "No skills to install", + }, + { + name: "interactive host selection via MultiSelect", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + Prompter: &prompter.PrompterMock{ + MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) { + return []int{0}, nil // select first agent + }, + SelectFunc: func(prompt string, defaultValue string, options []string) (int, error) { + return 0, nil // project scope + }, + }, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Force: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "scope prompt uses Remotes for repo name", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + pm := &prompter.PrompterMock{ + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil // project scope + }, + } + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + Remotes: func() (context.Remotes, error) { + return context.Remotes{ + {Remote: &git.Remote{Name: "origin"}, Repo: ghrepo.New("monalisa", "octocat-skills")}, + }, nil + }, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Agent: "github-copilot", + Force: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "interactive overwrite shows source info", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + destDir := t.TempDir() + existingContent := heredoc.Doc(` + --- + name: git-commit + description: Writes commits + metadata: + github-repo: https://github.com/someowner/somerepo + github-ref: v0.5.0 + --- + # Git Commit + `) + writeLocalTestSkill(t, destDir, "git-commit", existingContent) + pm := &prompter.PrompterMock{ + ConfirmFunc: func(prompt string, defaultValue bool) (bool, error) { + assert.Contains(t, prompt, "someowner/somerepo@v0.5.0") + return true, nil + }, + } + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: destDir, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "unsupported host returns error", + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "acme.ghes.com/monalisa/octocat-skills", + SkillName: "git-commit", + } + }, + wantErr: "supports only github.com", + }, + { + name: "select all skills in interactive prompt", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + // Blob stub for FetchDescriptionsConcurrent + encoded := base64.StdEncoding.EncodeToString([]byte(gitCommitContent)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-gc"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blob-gc", "content": %q, "encoding": "base64"}`, encoded))) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + pm := &prompter.PrompterMock{ + MultiSelectWithSearchFunc: func(prompt, searchPrompt string, defaults, persistentOptions []string, searchFunc func(string) prompter.MultiSelectSearchResult) ([]string, error) { + return []string{"(all skills)"}, nil + }, + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil + }, + } + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + Agent: "github-copilot", + Force: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "interactive repo prompt via Input", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + pm := &prompter.PrompterMock{ + InputFunc: func(prompt, defaultValue string) (string, error) { + return "monalisa/octocat-skills", nil + }, + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil + }, + } + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillName: "git-commit", + Agent: "github-copilot", + Force: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "interactive scope prompt selects user scope", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + pm := &prompter.PrompterMock{ + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 1, nil // user scope + }, + } + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Agent: "github-copilot", + Force: true, + } + }, + wantStdout: "~", + }, + { + name: "interactive overwrite without metadata shows plain prompt", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + destDir := t.TempDir() + // Existing skill without github metadata in frontmatter + writeLocalTestSkill(t, destDir, "git-commit", heredoc.Doc(` + --- + name: git-commit + description: No metadata + --- + # Git Commit + `)) + pm := &prompter.PrompterMock{ + ConfirmFunc: func(prompt string, defaultValue bool) (bool, error) { + assert.Contains(t, prompt, "already exists") + assert.NotContains(t, prompt, "installed from") + return true, nil + }, + } + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: destDir, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install single exact match by name", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + treeJSON := `{"path": "skills/alice", "type": "tree", "sha": "nsA"}, ` + + `{"path": "skills/alice/xlsx-pro", "type": "tree", "sha": "treeA"}, ` + + `{"path": "skills/alice/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobA"}, ` + + `{"path": "skills/git-commit", "type": "tree", "sha": "treeGC"}, ` + + `{"path": "skills/git-commit/SKILL.md", "type": "blob", "sha": "blobGC"}` + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeGC", "blobGC", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "multi-host install outputs per-host headers", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + // Two install rounds (one per host) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + pm := &prompter.PrompterMock{ + MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) { + return []int{0, 1}, nil // select two agents + }, + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil // project scope + }, + } + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Force: true, + } + }, + wantStdout: "Installed git-commit", + wantStderr: "Installing to", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + + if tt.stubs != nil { + tt.stubs(reg) + } + if tt.setup != nil { + tt.setup(t) + } + + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(tt.isTTY) + ios.SetStdinTTY(tt.isTTY) + ios.SetStderrTTY(tt.isTTY) + opts := tt.opts(ios, reg) + + err := installRun(opts) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + if tt.wantStdout != "" { + assert.Contains(t, stdout.String(), tt.wantStdout) + } + if tt.wantStderr != "" { + assert.Contains(t, stderr.String(), tt.wantStderr) + } + if tt.verify != nil { + tt.verify(t) + } + }) + } +} + +func TestInstallProgress(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + assert.Nil(t, installProgress(ios, 0)) + assert.NotNil(t, installProgress(ios, 1)) + assert.NotNil(t, installProgress(ios, 2)) +} + +func TestInstallRun_DeduplicatesSharedProjectDirAcrossHosts(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + pm := &prompter.PrompterMock{ + MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) { + return []int{0, 2}, nil // GitHub Copilot + Cursor share .agents/skills + }, + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil // project scope + }, + } + + err := installRun(&InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Force: true, + }) + require.NoError(t, err) + assert.Equal(t, 1, strings.Count(stdout.String(), "Installed git-commit")) + assert.NotContains(t, stderr.String(), "Installing to") +} + +func TestRunLocalInstall(t *testing.T) { + tests := []struct { + name string + isTTY bool + setup func(t *testing.T, sourceDir, targetDir string) + opts func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions + verify func(t *testing.T, targetDir string) + wantErr string + wantStdout string + wantStderr string + }{ + { + name: "installs skill with local-path metadata", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "git-commit", + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + verify: func(t *testing.T, targetDir string) { + t.Helper() + data, err := os.ReadFile(filepath.Join(targetDir, "git-commit", "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(data), "local-path") + }, + wantStdout: "Installed git-commit", + }, + { + name: "single skill directory (SKILL.md at root)", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + content := heredoc.Doc(` + --- + name: direct-skill + description: Direct + --- + # Direct + `) + require.NoError(t, os.WriteFile(filepath.Join(sourceDir, "SKILL.md"), []byte(content), 0o644)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "direct-skill", + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "Installed direct-skill", + }, + { + name: "namespaced skills install to separate directories", + isTTY: true, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + for _, ns := range []string{"alice", "bob"} { + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", ns, "xlsx-pro"), + fmt.Sprintf("---\nname: xlsx-pro\ndescription: %s xlsx-pro\n---\n# Test\n", ns)) + } + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + pm := &prompter.PrompterMock{ + MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { + return []string{allSkillsKey}, nil + }, + } + return &InstallOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + Prompter: pm, + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + verify: func(t *testing.T, targetDir string) { + t.Helper() + _, err := os.Stat(filepath.Join(targetDir, "alice", "xlsx-pro", "SKILL.md")) + assert.NoError(t, err, "alice/xlsx-pro should be installed") + _, err = os.Stat(filepath.Join(targetDir, "bob", "xlsx-pro", "SKILL.md")) + assert.NoError(t, err, "bob/xlsx-pro should be installed") + }, + wantStdout: "Installed alice/xlsx-pro", + }, + { + name: "local install with --force overwrites namespaced skill", + isTTY: true, + setup: func(t *testing.T, sourceDir, targetDir string) { + t.Helper() + for _, ns := range []string{"alice", "bob"} { + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", ns, "xlsx-pro"), + fmt.Sprintf("---\nname: xlsx-pro\ndescription: %s xlsx-pro\n---\n# Test\n", ns)) + } + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "alice", "xlsx-pro"), 0o755)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + pm := &prompter.PrompterMock{ + MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { + return []string{allSkillsKey}, nil + }, + } + return &InstallOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + Prompter: pm, + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "Installed", + }, + { + name: "local install existing skill without force non-interactive errors", + isTTY: false, + setup: func(t *testing.T, sourceDir, targetDir string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "git-commit"), 0o755)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantErr: "already installed", + }, + { + name: "local install with no skills found errors", + isTTY: false, + setup: func(_ *testing.T, _, _ string) {}, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "anything", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantErr: "no skills found", + }, + { + name: "local install outputs review hint", + isTTY: true, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "git-commit", + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStderr: "Review the installed files before use", + }, + { + name: "local install with --agent claude-code", + isTTY: true, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "git-commit", + Force: true, + Agent: "claude-code", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "local install by skill name selects one", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "code-review"), heredoc.Doc(` + --- + name: code-review + description: Reviews code + --- + # Code Review + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "git-commit", + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "local install outputs file tree for TTY", + isTTY: true, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + skillDir := filepath.Join(sourceDir, "skills", "git-commit") + require.NoError(t, os.MkdirAll(filepath.Join(skillDir, "scripts"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), + []byte("---\nname: git-commit\ndescription: Commits\n---\n# A\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "scripts", "run.sh"), + []byte("#!/bin/bash"), 0o644)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "git-commit", + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStderr: "SKILL.md", + }, + { + name: "local path with tilde expansion", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + t.Setenv("HOME", sourceDir) + t.Setenv("USERPROFILE", sourceDir) + return &InstallOptions{ + IO: ios, + SkillSource: "~/", + localPath: "~/", + SkillName: "git-commit", + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "local path with bare tilde expansion", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + t.Setenv("HOME", sourceDir) + t.Setenv("USERPROFILE", sourceDir) + return &InstallOptions{ + IO: ios, + SkillSource: "~", + localPath: "~", + SkillName: "git-commit", + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "local skill not found by name", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "nonexistent-skill", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantErr: "not found in local directory", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + sourceDir := t.TempDir() + targetDir := t.TempDir() + + if tt.setup != nil { + tt.setup(t, sourceDir, targetDir) + } + + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(tt.isTTY) + ios.SetStdinTTY(tt.isTTY) + ios.SetStderrTTY(tt.isTTY) + opts := tt.opts(ios, sourceDir, targetDir) + + err := installRun(opts) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + if tt.wantStdout != "" { + assert.Contains(t, stdout.String(), tt.wantStdout) + } + if tt.wantStderr != "" { + assert.Contains(t, stderr.String(), tt.wantStderr) + } + if tt.verify != nil { + tt.verify(t, targetDir) + } + }) + } +} + +func Test_printReviewHint(t *testing.T) { + tests := []struct { + name string + repo string + sha string + skillNames []string + wantOutput string + }{ + { + name: "remote install with SHA includes SHA in preview command", + repo: "owner/repo", + sha: "abc123def456", + skillNames: []string{"my-skill"}, + wantOutput: "gh skill preview owner/repo my-skill@abc123def456", + }, + { + name: "remote install without SHA omits SHA from preview command", + repo: "owner/repo", + sha: "", + skillNames: []string{"my-skill"}, + wantOutput: "gh skill preview owner/repo my-skill\n", + }, + { + name: "multiple skills with SHA", + repo: "owner/repo", + sha: "deadbeef", + skillNames: []string{"skill-a", "skill-b"}, + wantOutput: "skill-a@deadbeef", + }, + { + name: "local install shows generic message", + repo: "", + sha: "", + skillNames: []string{"my-skill"}, + wantOutput: "Review the installed files before use", + }, + { + name: "no skills produces no output", + repo: "owner/repo", + sha: "abc123", + skillNames: []string{}, + wantOutput: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + cs := ios.ColorScheme() + var buf strings.Builder + printReviewHint(&buf, cs, tt.repo, tt.sha, tt.skillNames) + if tt.wantOutput == "" { + assert.Empty(t, buf.String()) + } else { + assert.Contains(t, buf.String(), tt.wantOutput) + } + }) + } +} + +func Test_printPreInstallDisclaimer(t *testing.T) { + ios, _, _, _ := iostreams.Test() + cs := ios.ColorScheme() + var buf strings.Builder + printPreInstallDisclaimer(&buf, cs) + output := buf.String() + assert.Contains(t, output, "not verified by GitHub") + assert.Contains(t, output, "prompt") + assert.Contains(t, output, "malicious") +} + +func Test_selectSkillsWithSelector_noDisclaimer(t *testing.T) { + ios, _, _, stderr := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + skills := []discovery.Skill{ + {Name: "git-commit", Convention: "skills", Path: "skills/git-commit/SKILL.md"}, + } + + pm := &prompter.PrompterMock{ + MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { + return []string{"git-commit"}, nil + }, + } + + opts := &InstallOptions{ + IO: ios, + Prompter: pm, + } + + _, err := selectSkillsWithSelector(opts, skills, true, skillSelector{ + matchByName: matchSkillByName, + sourceHint: "owner/repo", + }) + require.NoError(t, err) + assert.NotContains(t, stderr.String(), "not verified by GitHub") +} diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go new file mode 100644 index 000000000..e39886ecd --- /dev/null +++ b/pkg/cmd/skills/preview/preview.go @@ -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 []", + 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) + } + } +} diff --git a/pkg/cmd/skills/preview/preview_test.go b/pkg/cmd/skills/preview/preview_test.go new file mode 100644 index 000000000..474ce88b5 --- /dev/null +++ b/pkg/cmd/skills/preview/preview_test.go @@ -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") + }) +} diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go new file mode 100644 index 000000000..213afeba5 --- /dev/null +++ b/pkg/cmd/skills/publish/publish.go @@ -0,0 +1,1132 @@ +package publish + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "path" + "path/filepath" + "regexp" + "sort" + "strconv" + "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/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" +) + +// PublishOptions holds all dependencies and user-provided flags for the publish command. +type PublishOptions struct { + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + Config func() (gh.Config, error) + Prompter prompter.Prompter + GitClient *git.Client + + Dir string + Fix bool + DryRun bool + Tag string + + client *api.Client // injectable for tests; nil means use factory + host string // resolved from config in production +} + +// publishDiagnostic is a single validation finding. +type publishDiagnostic struct { + skill string // empty for repo-level issues + severity string // "error", "warning", "fixed", or "info" + message string +} + +// repoTopicsResponse is the response from the repo topics API. +type repoTopicsResponse struct { + Names []string `json:"names"` +} + +// tagEntry is a single tag from the tags list API. +type tagEntry struct { + Name string `json:"name"` +} + +// rulesetsResponse is a single ruleset from the rulesets API. +type rulesetsResponse struct { + ID int `json:"id"` + Name string `json:"name"` + Target string `json:"target"` + Enforcement string `json:"enforcement"` +} + +// securityAnalysis represents the security_and_analysis field from the repo API. +type securityAnalysis struct { + AdvancedSecurity *securityFeature `json:"advanced_security"` + SecretScanning *securityFeature `json:"secret_scanning"` + SecretScanningPushProtection *securityFeature `json:"secret_scanning_push_protection"` +} + +type securityFeature struct { + Status string `json:"status"` +} + +// repoSecurityResponse is the subset of repo API we need for security checks. +type repoSecurityResponse struct { + SecurityAndAnalysis *securityAnalysis `json:"security_and_analysis"` +} + +// NewCmdPublish creates the "skills publish" command. +func NewCmdPublish(f *cmdutil.Factory, runF func(*PublishOptions) error) *cobra.Command { + opts := &PublishOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + Prompter: f.Prompter, + GitClient: f.GitClient, + } + + cmd := &cobra.Command{ + Use: "publish [] [flags]", + Short: "Validate and publish skills to a GitHub repository (preview)", + Long: heredoc.Docf(` + Validate a local repository's skills against the Agent Skills specification + and publish them by creating a GitHub release. + + Skills are discovered using the same conventions as install: + + - %[1]sskills/*/SKILL.md%[1]s + - %[1]sskills/{scope}/*/SKILL.md%[1]s + - %[1]s*/SKILL.md%[1]s (root-level) + - %[1]splugins/{scope}/skills/*/SKILL.md%[1]s + + Validation checks include: + + - Skill names match the strict agentskills.io naming rules + - Each skill name matches its directory name + - Required frontmatter fields (name, description) are present + - allowed-tools is a string, not an array + - Install metadata (%[1]smetadata.github-*%[1]s) is stripped if present + + After validation passes, publish will interactively guide you through: + + - Adding the %[1]sagent-skills%[1]s topic to the repository + - Choosing a version tag (semver recommended) + - Creating a GitHub release with auto-generated notes + + Use %[1]s--dry-run%[1]s to validate without publishing. + Use %[1]s--tag%[1]s to publish non-interactively with a specific tag. + Use %[1]s--fix%[1]s to automatically strip install metadata from committed files. + `, "`"), + Example: heredoc.Doc(` + # Validate and publish interactively + $ gh skill publish + + # Publish with a specific tag (non-interactive) + $ gh skill publish --tag v1.0.0 + + # Validate only (no publish) + $ gh skill publish --dry-run + + # Validate and strip install metadata + $ gh skills publish --fix + `), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 1 { + opts.Dir = args[0] + } + if runF != nil { + return runF(opts) + } + return publishRun(opts) + }, + } + + cmd.Flags().BoolVar(&opts.Fix, "fix", false, "Auto-fix issues where possible (e.g. strip install metadata)") + cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Validate without publishing") + cmd.Flags().StringVar(&opts.Tag, "tag", "", "Version tag for the release (e.g. v1.0.0)") + + return cmd +} + +func publishRun(opts *PublishOptions) error { + dir := opts.Dir + if dir == "" { + var err error + dir, err = os.Getwd() + if err != nil { + return fmt.Errorf("could not determine working directory: %w", err) + } + } + + dir, err := filepath.Abs(dir) + if err != nil { + return fmt.Errorf("could not resolve path: %w", err) + } + + canPrompt := opts.IO.CanPrompt() + + // Use injected client or create one from the factory HttpClient. + // Initialization is deferred until after local validation so that + // simple errors (missing skills/, bad SKILL.md, etc.) are reported + // without requiring an HTTP client. + client := opts.client + host := opts.host + + var diagnostics []publishDiagnostic + + skills, err := discovery.DiscoverLocalSkills(dir) + if err != nil { + return err + } + + for _, skill := range skills { + dirName := path.Base(skill.Path) + skillPath := filepath.Join(dir, filepath.FromSlash(skill.Path), "SKILL.md") + content, err := os.ReadFile(skillPath) + if err != nil { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: skill.DisplayName(), + severity: "error", + message: "missing SKILL.md file", + }) + continue + } + + result, err := frontmatter.Parse(string(content)) + if err != nil { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: skill.DisplayName(), + severity: "error", + message: fmt.Sprintf("invalid frontmatter YAML: %s", err), + }) + continue + } + + // Validate name field exists + if result.Metadata.Name == "" { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: skill.DisplayName(), + severity: "error", + message: "missing required field: name", + }) + } else { + // Validate name matches directory + if result.Metadata.Name != dirName { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: skill.DisplayName(), + severity: "error", + message: fmt.Sprintf("name %q does not match directory name %q", result.Metadata.Name, dirName), + }) + } + + // Validate name is spec-compliant + if !discovery.IsSpecCompliant(result.Metadata.Name) { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: skill.DisplayName(), + severity: "error", + message: fmt.Sprintf("name %q does not follow agentskills.io naming convention (lowercase alphanumeric + hyphens)", result.Metadata.Name), + }) + } + } + + // Validate description field exists + if result.Metadata.Description == "" { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: skill.DisplayName(), + severity: "error", + message: "missing required field: description", + }) + } else if len(result.Metadata.Description) > 1024 { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: skill.DisplayName(), + severity: "warning", + message: fmt.Sprintf("description is %d chars (recommended max: 1024)", len(result.Metadata.Description)), + }) + } + + // Validate allowed-tools is string, not array + if raw, ok := result.RawYAML["allowed-tools"]; ok { + if _, isSlice := raw.([]interface{}); isSlice { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: skill.DisplayName(), + severity: "error", + message: "allowed-tools must be a string (space-delimited), not an array", + }) + } + } + + // Check for install metadata that should be stripped + if meta, ok := result.RawYAML["metadata"].(map[string]interface{}); ok { + githubKeys := findGitHubMetadataKeys(meta) + if len(githubKeys) > 0 { + if opts.Fix { + fixed, fixErr := stripGitHubMetadata(string(content)) + if fixErr != nil { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: skill.DisplayName(), + severity: "error", + message: fmt.Sprintf("could not strip install metadata: %s", fixErr), + }) + } else if writeErr := os.WriteFile(skillPath, []byte(fixed), 0o644); writeErr != nil { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: skill.DisplayName(), + severity: "error", + message: fmt.Sprintf("could not write fixed SKILL.md: %s", writeErr), + }) + } else { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: skill.DisplayName(), + severity: "fixed", + message: fmt.Sprintf("stripped install metadata: %s", strings.Join(githubKeys, ", ")), + }) + } + } else { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: skill.DisplayName(), + severity: "error", + message: fmt.Sprintf("contains install metadata that must be stripped: %s (use --fix)", strings.Join(githubKeys, ", ")), + }) + } + } + } + + // Recommended: license field + if result.Metadata.License == "" { + if _, ok := result.RawYAML["license"]; !ok { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: skill.DisplayName(), + severity: "warning", + message: "recommended field missing: license", + }) + } + } + + // Recommended: body length + bodyLines := strings.Count(result.Body, "\n") + 1 + if bodyLines > 500 { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: skill.DisplayName(), + severity: "warning", + message: fmt.Sprintf("skill body is %d lines (recommended max: 500 for efficient context)", bodyLines), + }) + } + } + + // Check for installed skill directories that should be gitignored + installedDirDiags := checkInstalledSkillDirs(opts.GitClient, dir) + diagnostics = append(diagnostics, installedDirDiags...) + + // Remote repository checks (best-effort) + repoInfo, remoteErr := detectGitHubRemote(opts.GitClient, dir) + if remoteErr != nil { + return remoteErr + } + owner, repo := "", "" + if repoInfo != nil { + owner = repoInfo.Repo.RepoOwner() + repo = repoInfo.Repo.RepoName() + } + + hasTopic := false + var existingTags []tagEntry + if owner != "" && repo != "" { + // Create API client from factory if not already injected (tests inject directly). + if client == nil { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + client = api.NewClientFromHTTP(httpClient) + } + + if host == "" && repoInfo != nil { + host = repoInfo.Repo.RepoHost() + } + if host == "" { + cfg, err := opts.Config() + if err != nil { + return err + } + host, _ = cfg.Authentication().DefaultHost() + } + if err := source.ValidateSupportedHost(host); err != nil { + return err + } + + // Security and ruleset checks (advisory, always shown) + var skillAbsDirs []string + for _, skill := range skills { + skillAbsDirs = append(skillAbsDirs, filepath.Join(dir, filepath.FromSlash(skill.Path))) + } + securityDiags := checkSecuritySettings(client, host, owner, repo, skillAbsDirs) + diagnostics = append(diagnostics, securityDiags...) + + rulesetDiags := checkTagProtection(client, host, owner, repo) + diagnostics = append(diagnostics, rulesetDiags...) + + // Check topic (needed for publish flow, not a blocking error) + hasTopic = repoHasTopic(client, host, owner, repo) + + // Fetch existing tags (needed for version suggestion) + existingTags = fetchTags(client, host, owner, repo) + } else { + diagnostics = append(diagnostics, detectMissingRepoDiagnostic(opts.GitClient, dir)...) + } + + // Render diagnostics + errors, warnings, fixes := 0, 0, 0 + for _, d := range diagnostics { + switch d.severity { + case "error": + errors++ + case "warning": + warnings++ + case "fixed": + fixes++ + } + } + + if canPrompt { + renderDiagnosticsTTY(opts, len(skills), diagnostics, errors, warnings, fixes, owner, repo) + } else { + renderDiagnosticsPlain(opts, diagnostics, errors, warnings) + } + + if errors > 0 { + return fmt.Errorf("validation failed with %d error(s)", errors) + } + + // --- Publish flow --- + if opts.DryRun { + fmt.Fprintf(opts.IO.ErrOut, "\nDry run complete. Use without --dry-run to publish.\n") + return nil + } + + if owner == "" || repo == "" { + fmt.Fprintf(opts.IO.ErrOut, "\nValidation passed. Set up a GitHub remote to publish.\n") + return nil + } + + if !canPrompt && opts.Tag == "" { + fmt.Fprintf(opts.IO.ErrOut, "\nValidation passed. Use --tag to publish non-interactively.\n") + return nil + } + + fmt.Fprintf(opts.IO.ErrOut, "\nPublishing to %s/%s...\n\n", owner, repo) + + return runPublishRelease(opts, client, host, owner, repo, dir, repoInfo.RemoteName, hasTopic, existingTags) +} + +// repoHasTopic checks whether the repo has the agent-skills topic. +func repoHasTopic(client *api.Client, host, owner, repo string) bool { + if client == nil { + return false + } + apiPath := fmt.Sprintf("repos/%s/%s/topics", owner, repo) + var resp repoTopicsResponse + if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { + return false + } + for _, t := range resp.Names { + if t == "agent-skills" { + return true + } + } + return false +} + +// fetchTags returns the most recent tags from the repo. +func fetchTags(client *api.Client, host, owner, repo string) []tagEntry { + if client == nil { + return nil + } + apiPath := fmt.Sprintf("repos/%s/%s/tags?per_page=10", owner, repo) + var tags []tagEntry + if err := client.REST(host, "GET", apiPath, nil, &tags); err != nil { + return nil + } + return tags +} + +// runPublishRelease handles the interactive publish flow: topic, tag, release, immutability. +func runPublishRelease(opts *PublishOptions, client *api.Client, host, owner, repo, dir, remoteName string, hasTopic bool, existingTags []tagEntry) error { + cs := opts.IO.ColorScheme() + canPrompt := opts.IO.CanPrompt() + + // Add topic if missing + if !hasTopic { + addTopic := true + if canPrompt { + var err error + addTopic, err = opts.Prompter.Confirm( + fmt.Sprintf("Add \"agent-skills\" topic to %s/%s? (required for discoverability)", owner, repo), true) + if err != nil { + return err + } + } + if addTopic { + if err := addAgentSkillsTopic(client, host, owner, repo); err != nil { + fmt.Fprintf(opts.IO.ErrOut, "%s Could not add topic: %v\n", cs.WarningIcon(), err) + fmt.Fprintf(opts.IO.ErrOut, " Add it manually: gh repo edit %s/%s --add-topic agent-skills\n", owner, repo) + } else { + fmt.Fprintf(opts.IO.Out, "%s Added \"agent-skills\" topic\n", cs.SuccessIcon()) + } + } + } + + // Push unpushed commits (like gh pr create) + if err := ensurePushed(opts, dir, remoteName); err != nil { + return err + } + + // Determine tag + tag := opts.Tag + if tag == "" { + suggested := "v1.0.0" + if len(existingTags) > 0 { + if next := suggestNextTag(existingTags[0].Name); next != "" { + suggested = next + } + } + + if canPrompt { + strategies := []string{ + fmt.Sprintf("Semver (recommended): %s", suggested), + "Custom tag", + } + idx, err := opts.Prompter.Select("Tagging strategy:", "", strategies) + if err != nil { + return err + } + + if idx == 0 { + tag = suggested + edited, err := opts.Prompter.Input(fmt.Sprintf("Version tag [%s]:", suggested), suggested) + if err != nil { + return err + } + if edited != "" { + tag = edited + } + } else { + custom, err := opts.Prompter.Input("Tag:", "") + if err != nil { + return err + } + if custom == "" { + return fmt.Errorf("tag is required") + } + tag = custom + } + } else { + return fmt.Errorf("--tag is required for non-interactive publish") + } + } + + // Validate tag doesn't already exist + for _, t := range existingTags { + if t.Name == tag { + return fmt.Errorf("tag %s already exists; choose a different version", tag) + } + } + + // Offer to enable immutable releases + immutableEnabled := checkImmutableReleases(client, host, owner, repo) + if !immutableEnabled && canPrompt { + enableImmutable, err := opts.Prompter.Confirm( + "Enable immutable releases? (prevents tampering with published releases)", true) + if err != nil { + return err + } + if enableImmutable { + if err := enableImmutableReleases(client, host, owner, repo); err != nil { + fmt.Fprintf(opts.IO.ErrOut, "%s Could not enable immutable releases: %v\n", cs.WarningIcon(), err) + fmt.Fprintf(opts.IO.ErrOut, " Enable manually in Settings > General > Releases\n") + } else { + fmt.Fprintf(opts.IO.Out, "%s Enabled immutable releases\n", cs.SuccessIcon()) + } + } + } + + // Inform if not on default branch + var currentBranch string + if opts.GitClient != nil { + branchGitClient := opts.GitClient.Copy() + branchGitClient.RepoDir = dir + if b, err := branchGitClient.CurrentBranch(context.Background()); err == nil { + currentBranch = b + } + } + defaultBranch := detectDefaultBranch(client, host, owner, repo) + if currentBranch != "" && defaultBranch != "" && currentBranch != defaultBranch { + fmt.Fprintf(opts.IO.ErrOut, "%s Publishing from branch %q (default is %q)\n", cs.WarningIcon(), currentBranch, defaultBranch) + } + + // Confirm and create release + if canPrompt { + confirmed, err := opts.Prompter.Confirm( + fmt.Sprintf("Create release %s with auto-generated notes?", tag), true) + if err != nil { + return err + } + if !confirmed { + fmt.Fprintf(opts.IO.ErrOut, "Publish cancelled.\n") + return cmdutil.CancelError + } + } + + // Create release via REST API + releaseBody := map[string]interface{}{ + "tag_name": tag, + "generate_release_notes": true, + } + if currentBranch != "" { + releaseBody["target_commitish"] = currentBranch + } + releaseJSON, err := json.Marshal(releaseBody) + if err != nil { + return fmt.Errorf("failed to serialize release request: %w", err) + } + + releasePath := fmt.Sprintf("repos/%s/%s/releases", owner, repo) + var releaseResp struct { + HTMLURL string `json:"html_url"` + } + if err := client.REST(host, "POST", releasePath, bytes.NewReader(releaseJSON), &releaseResp); err != nil { + return fmt.Errorf("failed to create release: %w", err) + } + + fmt.Fprintf(opts.IO.Out, "%s Published %s\n", cs.SuccessIcon(), tag) + fmt.Fprintf(opts.IO.Out, "%s Install with: gh skill install %s/%s\n", cs.SuccessIcon(), owner, repo) + fmt.Fprintf(opts.IO.Out, "%s Pin with: gh skill install %s/%s --pin %s\n", cs.SuccessIcon(), owner, repo, tag) + + return nil +} + +// ensurePushed checks whether the current branch has unpushed commits and +// pushes them automatically, consistent with how gh pr create behaves. +func ensurePushed(opts *PublishOptions, dir, remoteName string) error { + if opts.GitClient == nil { + return nil + } + + cs := opts.IO.ColorScheme() + gitClient := opts.GitClient.Copy() + gitClient.RepoDir = dir + + ctx := context.Background() + currentBranch, err := gitClient.CurrentBranch(ctx) + if err != nil { + return nil //nolint:nilerr // not on a branch (detached HEAD); skip push check + } + + // Count commits ahead of the push target (remote tracking branch). + // If the branch has no upstream, rev-list will fail; we treat that as + // "everything is unpushed" and push the whole branch. + unpushed := 0 + revCmd, err := gitClient.Command(ctx, "rev-list", "--count", "@{push}..HEAD") + if err != nil { + return fmt.Errorf("could not check unpushed commits: %w", err) + } + out, revErr := revCmd.Output() + if revErr != nil { + // @{push} not resolvable; branch has never been pushed + unpushed = -1 + } else { + n, parseErr := strconv.Atoi(strings.TrimSpace(string(out))) + if parseErr != nil { + return fmt.Errorf("could not parse unpushed commit count: %w", parseErr) + } + unpushed = n + } + + if unpushed == 0 { + return nil + } + + ref := fmt.Sprintf("HEAD:refs/heads/%s", currentBranch) + fmt.Fprintf(opts.IO.ErrOut, "%s Pushing %s to %s\n", cs.SuccessIcon(), currentBranch, remoteName) + if err := gitClient.Push(ctx, remoteName, ref); err != nil { + return fmt.Errorf("failed to push branch %s: %w", currentBranch, err) + } + + return nil +} + +// detectDefaultBranch returns the default branch of the remote repo via the API. +func detectDefaultBranch(client *api.Client, host, owner, repo string) string { + if client == nil { + return "" + } + var result struct { + DefaultBranch string `json:"default_branch"` + } + if err := client.REST(host, "GET", fmt.Sprintf("repos/%s/%s", owner, repo), nil, &result); err != nil { + return "" + } + return result.DefaultBranch +} + +// addAgentSkillsTopic adds the "agent-skills" topic to the repo, preserving existing topics. +func addAgentSkillsTopic(client *api.Client, host, owner, repo string) error { + apiPath := fmt.Sprintf("repos/%s/%s/topics", owner, repo) + + // Fetch existing topics + var resp repoTopicsResponse + if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { + return fmt.Errorf("could not fetch existing topics: %w", err) + } + + // Deduplicate: only add if not already present + for _, t := range resp.Names { + if t == "agent-skills" { + return nil + } + } + + topics := append(resp.Names, "agent-skills") + topicsJSON, err := json.Marshal(map[string][]string{"names": topics}) + if err != nil { + return fmt.Errorf("could not serialize topics: %w", err) + } + return client.REST(host, "PUT", apiPath, bytes.NewReader(topicsJSON), nil) +} + +// checkImmutableReleases checks if immutable releases are enabled for the repo. +func checkImmutableReleases(client *api.Client, host, owner, repo string) bool { + if client == nil { + return false + } + apiPath := fmt.Sprintf("repos/%s/%s/immutable-releases", owner, repo) + var resp struct { + Enabled bool `json:"enabled"` + } + if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { + return false + } + return resp.Enabled +} + +// enableImmutableReleases enables immutable releases for the repo. +func enableImmutableReleases(client *api.Client, host, owner, repo string) error { + apiPath := fmt.Sprintf("repos/%s/%s/immutable-releases", owner, repo) + body := bytes.NewReader([]byte(`{"enabled":true}`)) + return client.REST(host, "PATCH", apiPath, body, nil) +} + +// checkTagProtection checks whether tag protection rulesets are enabled. +func checkTagProtection(client *api.Client, host, owner, repo string) []publishDiagnostic { + if client == nil { + return nil + } + apiPath := fmt.Sprintf("repos/%s/%s/rulesets", owner, repo) + var rulesets []rulesetsResponse + if err := client.REST(host, "GET", apiPath, nil, &rulesets); err != nil { + return nil + } + + for _, rs := range rulesets { + if rs.Target == "tag" && rs.Enforcement == "active" { + return nil + } + } + + return []publishDiagnostic{{ + severity: "warning", + message: "no active tag protection rulesets found. Consider protecting tags to ensure immutable releases (Settings > Rules > Rulesets)", + }} +} + +// checkSecuritySettings checks whether recommended security features are enabled. +func checkSecuritySettings(client *api.Client, host, owner, repo string, skillDirs []string) []publishDiagnostic { + if client == nil { + return nil + } + apiPath := fmt.Sprintf("repos/%s/%s", owner, repo) + var resp repoSecurityResponse + if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { + return nil + } + + if resp.SecurityAndAnalysis == nil { + return nil + } + + var diagnostics []publishDiagnostic + sa := resp.SecurityAndAnalysis + + if sa.SecretScanning == nil || sa.SecretScanning.Status != "enabled" { + diagnostics = append(diagnostics, publishDiagnostic{ + severity: "warning", + message: "secret scanning is not enabled. Recommended to prevent accidental credential exposure (gh repo edit --enable-secret-scanning)", + }) + } + + if sa.SecretScanningPushProtection == nil || sa.SecretScanningPushProtection.Status != "enabled" { + diagnostics = append(diagnostics, publishDiagnostic{ + severity: "warning", + message: "secret scanning push protection is not enabled. Blocks pushes containing secrets (gh repo edit --enable-secret-scanning-push-protection)", + }) + } + + hasCode, hasManifests := detectCodeAndManifests(skillDirs) + + if hasCode { + alertsPath := fmt.Sprintf("repos/%s/%s/code-scanning/alerts?per_page=1&state=open", owner, repo) + if err := client.REST(host, "GET", alertsPath, nil, new([]interface{})); err != nil { + diagnostics = append(diagnostics, publishDiagnostic{ + severity: "info", + message: "skills include code files but code scanning does not appear to be configured (Settings > Code security > Code scanning)", + }) + } + } + + if hasManifests { + dependabotPath := fmt.Sprintf("repos/%s/%s/vulnerability-alerts", owner, repo) + if err := client.REST(host, "GET", dependabotPath, nil, nil); err != nil { + diagnostics = append(diagnostics, publishDiagnostic{ + severity: "info", + message: "skills include dependency manifests but Dependabot alerts do not appear to be enabled (Settings > Code security > Dependabot)", + }) + } + } + + return diagnostics +} + +// codeExtensions are file extensions that indicate code is present. +var codeExtensions = map[string]bool{ + ".go": true, ".py": true, ".js": true, ".ts": true, ".rb": true, + ".rs": true, ".java": true, ".cs": true, ".sh": true, ".bash": true, + ".zsh": true, ".ps1": true, ".swift": true, ".kt": true, ".c": true, + ".cpp": true, ".h": true, ".php": true, ".pl": true, ".lua": true, +} + +// manifestFiles are dependency manifest filenames. +var manifestFiles = map[string]bool{ + "package.json": true, "package-lock.json": true, "yarn.lock": true, + "go.mod": true, "go.sum": true, "Cargo.toml": true, "Cargo.lock": true, + "requirements.txt": true, "Pipfile": true, "Pipfile.lock": true, + "pyproject.toml": true, "poetry.lock": true, "Gemfile": true, + "Gemfile.lock": true, "pom.xml": true, "build.gradle": true, + "composer.json": true, "composer.lock": true, +} + +// detectCodeAndManifests walks the skill directories looking for code files +// and dependency manifests. +func detectCodeAndManifests(skillDirs []string) (hasCode, hasManifests bool) { + for _, dir := range skillDirs { + _ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + ext := filepath.Ext(info.Name()) + if codeExtensions[ext] { + hasCode = true + } + if manifestFiles[info.Name()] { + hasManifests = true + } + if hasCode && hasManifests { + // Stop walking this skill directory early; the outer loop + // continues to process remaining skill directories. + return filepath.SkipAll + } + return nil + }) + if hasCode && hasManifests { + return + } + } + return +} + +// checkInstalledSkillDirs warns when agent host skill directories exist +// in the repo and are not gitignored. +func checkInstalledSkillDirs(gitClient *git.Client, repoDir string) []publishDiagnostic { + var diagnostics []publishDiagnostic + + for _, relPath := range registry.UniqueProjectDirs() { + absPath := filepath.Join(repoDir, relPath) + if _, err := os.Stat(absPath); os.IsNotExist(err) { + continue + } + + if gitClient != nil { + ignoreGitClient := gitClient.Copy() + ignoreGitClient.RepoDir = repoDir + ignored, err := ignoreGitClient.IsIgnored(context.Background(), relPath) + if ignored { + continue + } + if err != nil { + diagnostics = append(diagnostics, publishDiagnostic{ + severity: "warning", + message: fmt.Sprintf("%s/ may contain installed skills that are not gitignored (could not verify: %v)", relPath, err), + }) + continue + } + } + + diagnostics = append(diagnostics, publishDiagnostic{ + severity: "warning", + message: fmt.Sprintf( + "%s/ contains installed skills and should be added to .gitignore to avoid publishing other authors' content", + relPath), + }) + } + + return diagnostics +} + +// semverPattern matches v-prefixed semver tags (e.g. v1.2.3). +var semverPattern = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)$`) + +// suggestNextTag increments the patch version of a semver tag. +func suggestNextTag(latest string) string { + m := semverPattern.FindStringSubmatch(latest) + if m == nil { + return "" + } + + prefix := "" + if strings.HasPrefix(latest, "v") { + prefix = "v" + } + + major, minor := m[1], m[2] + patch := 0 + fmt.Sscanf(m[3], "%d", &patch) + + return fmt.Sprintf("%s%s.%s.%d", prefix, major, minor, patch+1) +} + +// gitHubRemote holds a detected GitHub remote and its local name. +type gitHubRemote struct { + Repo ghrepo.Interface + RemoteName string +} + +// detectGitHubRemote attempts to detect the GitHub owner/repo from git remotes +// in the given directory. +func detectGitHubRemote(gitClient *git.Client, dir string) (*gitHubRemote, error) { + if gitClient == nil { + return nil, nil + } + + dirClient := gitClient.Copy() + dirClient.RepoDir = dir + + // Try origin first + if url, err := dirClient.RemoteURL(context.Background(), "origin"); err == nil { + repo, parseErr := parseGitHubURL(url) + if parseErr != nil { + return nil, parseErr + } + if repo != nil { + return &gitHubRemote{Repo: repo, RemoteName: "origin"}, nil + } + } + + // Fall back to any remote that points to GitHub + remotes, err := dirClient.Remotes(context.Background()) + if err != nil { + return nil, nil //nolint:nilerr // failing to list remotes is not an error; it just means no repo detected + } + for _, r := range remotes { + if r.Name == "origin" { + continue + } + if url, err := dirClient.RemoteURL(context.Background(), r.Name); err == nil { + repo, parseErr := parseGitHubURL(url) + if parseErr != nil { + return nil, parseErr + } + if repo != nil { + return &gitHubRemote{Repo: repo, RemoteName: r.Name}, nil + } + } + } + return nil, nil +} + +// parseGitHubURL extracts owner/repo from a GitHub remote URL. +// Only GitHub.com URLs are recognized. +func parseGitHubURL(rawURL string) (ghrepo.Interface, error) { + u, err := git.ParseURL(rawURL) + if err != nil { + return nil, nil //nolint:nilerr // unparseable URL means it's not a GitHub remote + } + r, err := ghrepo.FromURL(u) + if err != nil { + return nil, nil //nolint:nilerr // URL didn't match GitHub repo format + } + if err := source.ValidateSupportedHost(r.RepoHost()); err != nil { + return nil, nil //nolint:nilerr // non-GitHub host is silently ignored + } + return r, nil +} + +// detectMissingRepoDiagnostic explains why remote checks were skipped. +func detectMissingRepoDiagnostic(gitClient *git.Client, dir string) []publishDiagnostic { + if gitClient == nil { + return nil + } + + dirGitClient := gitClient.Copy() + dirGitClient.RepoDir = dir + if _, err := dirGitClient.GitDir(context.Background()); err != nil { + return []publishDiagnostic{{ + severity: "warning", + message: "not a git repository. Initialize with: git init && gh repo create", + }} + } + + remotes, err := dirGitClient.Remotes(context.Background()) + if err != nil || len(remotes) == 0 { + return []publishDiagnostic{{ + severity: "warning", + message: "no git remote found. Create a GitHub repository with: gh repo create", + }} + } + + var urls []string + for _, r := range remotes { + if url, err := dirGitClient.RemoteURL(context.Background(), r.Name); err == nil { + urls = append(urls, url) + } + } + return []publishDiagnostic{{ + severity: "warning", + message: fmt.Sprintf("remote %q is not a GitHub repository. Skills must be hosted on GitHub for discovery", strings.Join(urls, ", ")), + }} +} + +func renderDiagnosticsTTY(opts *PublishOptions, skillCount int, diagnostics []publishDiagnostic, errors, warnings, fixes int, owner, repo string) { + cs := opts.IO.ColorScheme() + + // Separate info messages from errors/warnings for cleaner output + var infos, issues []publishDiagnostic + for _, d := range diagnostics { + if d.severity == "info" { + infos = append(infos, d) + } else { + issues = append(issues, d) + } + } + + if len(issues) == 0 && fixes == 0 { + fmt.Fprintf(opts.IO.Out, "%s %d skill(s) validated successfully\n", cs.SuccessIcon(), skillCount) + } else { + for _, d := range issues { + var prefix string + switch d.severity { + case "error": + prefix = cs.FailureIcon() + case "warning": + prefix = cs.WarningIcon() + case "fixed": + prefix = cs.SuccessIcon() + default: + prefix = cs.FailureIcon() + } + if d.skill != "" { + fmt.Fprintf(opts.IO.Out, "%s %s: %s\n", prefix, cs.Bold(d.skill), d.message) + } else { + fmt.Fprintf(opts.IO.Out, "%s %s\n", prefix, d.message) + } + } + + fmt.Fprintln(opts.IO.Out) + if fixes > 0 { + fmt.Fprintf(opts.IO.Out, "Fixed %d issue(s)\n", fixes) + } + if errors > 0 { + fmt.Fprintf(opts.IO.Out, "%s, %s\n", + cs.Red(fmt.Sprintf("%d error(s)", errors)), + cs.Yellow(fmt.Sprintf("%d warning(s)", warnings))) + } else { + fmt.Fprintf(opts.IO.Out, "%s\n", cs.Yellow(fmt.Sprintf("%d warning(s)", warnings))) + } + } + + // Always show info messages + for _, d := range infos { + fmt.Fprintf(opts.IO.ErrOut, "\n%s\n", d.message) + } + + if errors == 0 { + if owner != "" && repo != "" { + fmt.Fprintf(opts.IO.ErrOut, "\n%s Repository: %s/%s\n", cs.Green("Ready to publish!"), owner, repo) + } else { + fmt.Fprintf(opts.IO.ErrOut, "\n%s Ensure the repository has the \"agent-skills\" topic.\n", cs.Green("Ready to publish!")) + } + } +} + +func renderDiagnosticsPlain(opts *PublishOptions, diagnostics []publishDiagnostic, errors, warnings int) { + for _, d := range diagnostics { + if d.severity == "info" { + continue + } + fmt.Fprintf(opts.IO.Out, "%s\t%s\t%s\n", d.severity, d.skill, d.message) + } + if errors == 0 && warnings == 0 { + fmt.Fprintf(opts.IO.Out, "ok\n") + } +} + +// findGitHubMetadataKeys returns metadata keys with the "github-" prefix. +func findGitHubMetadataKeys(meta map[string]interface{}) []string { + var keys []string + for k := range meta { + if strings.HasPrefix(k, "github-") { + keys = append(keys, k) + } + } + sort.Strings(keys) + return keys +} + +// stripGitHubMetadata removes github-* keys from the metadata map and re-serializes. +func stripGitHubMetadata(content string) (string, error) { + result, err := frontmatter.Parse(content) + if err != nil { + return "", err + } + + meta, ok := result.RawYAML["metadata"].(map[string]interface{}) + if !ok { + return content, nil + } + + for k := range meta { + if strings.HasPrefix(k, "github-") { + delete(meta, k) + } + } + + if len(meta) == 0 { + delete(result.RawYAML, "metadata") + } else { + result.RawYAML["metadata"] = meta + } + + return frontmatter.Serialize(result.RawYAML, result.Body) +} diff --git a/pkg/cmd/skills/publish/publish_test.go b/pkg/cmd/skills/publish/publish_test.go new file mode 100644 index 000000000..8c7205a3d --- /dev/null +++ b/pkg/cmd/skills/publish/publish_test.go @@ -0,0 +1,1692 @@ +package publish + +import ( + "bytes" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// initGitRepo initializes a git repo in the given directory and adds remotes. +// Use this when the git repo must live in the same directory as the skill files. +// A local bare repo is created as the push target so that ensurePushed can work +// during publish tests, while the fetch URL remains the GitHub URL so that +// detectGitHubRemote still resolves the correct owner/repo. +func initGitRepo(t *testing.T, dir string, remoteURLs map[string]string) { + t.Helper() + + bareDir := filepath.Join(t.TempDir(), "upstream.git") + require.NoError(t, os.MkdirAll(bareDir, 0o755)) + runGitInDir(t, bareDir, "init", "--bare", "--initial-branch=main") + + runGitInDir(t, dir, "init", "--initial-branch=main") + runGitInDir(t, dir, "config", "user.email", "monalisa@github.com") + runGitInDir(t, dir, "config", "user.name", "Monalisa Octocat") + for name, url := range remoteURLs { + runGitInDir(t, dir, "remote", "add", name, url) + runGitInDir(t, dir, "remote", "set-url", "--push", name, bareDir) + } + + runGitInDir(t, dir, "add", ".") + runGitInDir(t, dir, "commit", "--allow-empty", "-m", "init") + if _, ok := remoteURLs["origin"]; ok { + runGitInDir(t, dir, "push", "origin", "main") + } +} + +// stubAllSecureRemote registers the standard stubs for a fully-configured remote +// repo (topics, tags, rulesets, security) so publishRun skips all remote warnings. +func stubAllSecureRemote(reg *httpmock.Registry, owner, repo string) { + reg.Register( + httpmock.REST("GET", "repos/"+owner+"/"+repo+"/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"agent-skills"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/"+owner+"/"+repo+"/tags"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "v1.0.0"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/"+owner+"/"+repo+"/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "tags", "target": "tag", "enforcement": "active"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/"+owner+"/"+repo), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) +} + +func TestNewCmdPublish(t *testing.T) { + tests := []struct { + name string + cli string + wantsErr bool + wantsOpts PublishOptions + }{ + { + name: "all flags", + cli: "./monalisa-skills --dry-run --fix --tag v1.0.0", + wantsOpts: PublishOptions{ + Dir: "./monalisa-skills", + DryRun: true, + Fix: true, + Tag: "v1.0.0", + }, + }, + { + name: "directory only", + cli: "./octocat-repo", + wantsOpts: PublishOptions{ + Dir: "./octocat-repo", + }, + }, + { + name: "no args leaves dir empty", + cli: "", + wantsOpts: PublishOptions{}, + }, + { + name: "dry-run flag only", + cli: "--dry-run", + wantsOpts: PublishOptions{ + DryRun: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := cmdutil.Factory{IOStreams: ios} + + var gotOpts *PublishOptions + cmd := NewCmdPublish(&f, func(opts *PublishOptions) error { + gotOpts = opts + return nil + }) + + args, err := shlex.Split(tt.cli) + require.NoError(t, err) + cmd.SetArgs(args) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err = cmd.Execute() + if tt.wantsErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, gotOpts) + assert.Equal(t, tt.wantsOpts.Dir, gotOpts.Dir) + assert.Equal(t, tt.wantsOpts.DryRun, gotOpts.DryRun) + assert.Equal(t, tt.wantsOpts.Fix, gotOpts.Fix) + assert.Equal(t, tt.wantsOpts.Tag, gotOpts.Tag) + }) + } +} + +func TestPublishRun_UnsupportedHost(t *testing.T) { + dir := t.TempDir() + writeSkill(t, dir, "test-skill", heredoc.Doc(` + --- + name: test-skill + description: A test skill + --- + Body. + `)) + + ios, _, _, _ := iostreams.Test() + initGitRepo(t, dir, map[string]string{"origin": "https://github.com/monalisa/skills-repo.git"}) + err := publishRun(&PublishOptions{ + IO: ios, + Dir: dir, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{}), + host: "acme.ghes.com", + }) + require.ErrorContains(t, err, "supports only github.com") +} + +func TestPublishRun(t *testing.T) { + tests := []struct { + name string + isTTY bool + setup func(t *testing.T, dir string) + stubs func(*httpmock.Registry) + opts func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions + verify func(t *testing.T, dir string) + wantErr string + wantStdout string + wantStderr string + }{ + { + name: "no skills found", + setup: func(_ *testing.T, _ string) {}, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{IO: ios, Dir: dir} + }, + wantErr: "no skills found", + }, + { + name: "empty skills directory has no discoverable skills", + setup: func(t *testing.T, dir string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "skills", "empty-skill"), 0o755)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{IO: ios, Dir: dir} + }, + wantErr: "no skills found", + }, + { + name: "missing name in frontmatter", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "git-commit", heredoc.Doc(` + --- + description: A skill for writing good git commits + --- + Body text. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{IO: ios, Dir: dir} + }, + wantErr: "validation failed", + wantStdout: "missing required field: name", + }, + { + name: "name does not match directory", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "git-commit", heredoc.Doc(` + --- + name: wrong-name + description: A skill + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{IO: ios, Dir: dir} + }, + wantErr: "validation failed", + wantStdout: "does not match directory name", + }, + { + name: "non-spec-compliant name", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "My_Skill", heredoc.Doc(` + --- + name: My_Skill + description: A skill with non-compliant name + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{IO: ios, Dir: dir} + }, + wantErr: "validation failed", + wantStdout: "naming convention", + }, + { + name: "root-level skill discovered and validated", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + // Create a root-level skill (*/SKILL.md convention) + skillDir := filepath.Join(dir, "my-root-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: my-root-skill + description: A root-level skill + license: MIT + --- + Body. + `)), 0o644)) + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{ + IO: ios, + Dir: dir, + DryRun: true, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "1 skill(s) validated successfully", + wantStderr: "Dry run complete", + }, + { + name: "namespaced skill discovered and validated", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + // Create a namespaced skill (skills/{scope}/*/SKILL.md convention) + skillDir := filepath.Join(dir, "skills", "monalisa", "scoped-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: scoped-skill + description: A namespaced skill + license: MIT + --- + Body. + `)), 0o644)) + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{ + IO: ios, + Dir: dir, + DryRun: true, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "1 skill(s) validated successfully", + wantStderr: "Dry run complete", + }, + { + name: "valid skill dry-run passes validation", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "good-skill", heredoc.Doc(` + --- + name: good-skill + description: A good skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) + return &PublishOptions{ + IO: ios, + Dir: dir, + DryRun: true, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "1 skill(s) validated successfully", + wantStderr: "Dry run complete", + }, + { + name: "valid skill with --tag publishes release", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "git-commit", heredoc.Doc(` + --- + name: git-commit + description: A skill for writing good git commits + allowed-tools: git + license: MIT + --- + You are a git commit expert. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + // topic already present, so no PUT needed + // immutable releases check + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": true}), + ) + // default branch for branch comparison + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"}), + ) + // create release + reg.Register( + httpmock.REST("POST", "repos/monalisa/skills-repo/releases"), + httpmock.JSONResponse(map[string]interface{}{ + "html_url": "https://github.com/monalisa/skills-repo/releases/tag/v1.0.1", + }), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) + return &PublishOptions{ + IO: ios, + Dir: dir, + Tag: "v1.0.1", + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, def bool) (bool, error) { return true, nil }, + }, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "Published v1.0.1", + }, + { + name: "strip metadata with --fix", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "test-skill", heredoc.Doc(` + --- + name: test-skill + description: A test skill + metadata: + github-owner: someone + github-repo: something + github-ref: v1.0.0 + github-sha: abc123 + github-tree-sha: def456 + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{IO: ios, Dir: dir, Fix: true} + }, + wantStdout: "stripped install metadata", + verify: func(t *testing.T, dir string) { + t.Helper() + fixed, err := os.ReadFile(filepath.Join(dir, "skills", "test-skill", "SKILL.md")) + require.NoError(t, err) + fixedStr := string(fixed) + assert.NotContains(t, fixedStr, "github-owner") + assert.NotContains(t, fixedStr, "github-sha") + assert.NotContains(t, fixedStr, "metadata:") + }, + }, + { + name: "metadata without --fix errors with hint", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "test-skill", heredoc.Doc(` + --- + name: test-skill + description: A test skill + metadata: + github-owner: someone + github-sha: abc123 + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{IO: ios, Dir: dir, Fix: false} + }, + wantErr: "validation failed", + wantStdout: "--fix", + }, + { + name: "missing license warning", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "no-license", heredoc.Doc(` + --- + name: no-license + description: A skill without license + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{IO: ios, Dir: dir} + }, + wantStdout: "license", + }, + { + name: "allowed-tools array error", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "bad-tools", heredoc.Doc(` + --- + name: bad-tools + description: A skill with array allowed-tools + allowed-tools: + - git + - curl + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{IO: ios, Dir: dir} + }, + wantErr: "validation failed", + wantStdout: "allowed-tools must be a string", + }, + { + name: "security warnings when features disabled", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/octocat/secure-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"agent-skills"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/secure-repo/tags"), + httpmock.JSONResponse([]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/secure-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "branch-only", "target": "branch", "enforcement": "active"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/secure-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "disabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "disabled"}, + }, + }), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/octocat/secure-repo.git", + }) + return &PublishOptions{ + IO: ios, + Dir: dir, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "secret scanning is not enabled", + }, + { + name: "tag protection warning", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/octocat/tag-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"agent-skills"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/tag-repo/tags"), + httpmock.JSONResponse([]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/tag-repo/rulesets"), + httpmock.JSONResponse([]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/tag-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/octocat/tag-repo.git", + }) + return &PublishOptions{ + IO: ios, + Dir: dir, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "tag protection", + }, + { + name: "code files trigger code scanning info", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "code-skill", heredoc.Doc(` + --- + name: code-skill + description: A skill with code + license: MIT + --- + Body. + `)) + scriptDir := filepath.Join(dir, "skills", "code-skill", "scripts") + require.NoError(t, os.MkdirAll(scriptDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(scriptDir, "helper.sh"), []byte("#!/bin/bash"), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/octocat/code-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"agent-skills"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/code-repo/tags"), + httpmock.JSONResponse([]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/code-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "tags", "target": "tag", "enforcement": "active"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/code-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/code-repo/code-scanning/alerts"), + httpmock.StatusStringResponse(404, "not found"), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/octocat/code-repo.git", + }) + return &PublishOptions{ + IO: ios, + Dir: dir, + DryRun: true, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStderr: "code scanning", + }, + { + name: "manifest files trigger dependabot info", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "dep-skill", heredoc.Doc(` + --- + name: dep-skill + description: A skill with manifests + license: MIT + --- + Body. + `)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "skills", "dep-skill", "package.json"), + []byte("{}"), 0o644, + )) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/octocat/dep-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"agent-skills"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/dep-repo/tags"), + httpmock.JSONResponse([]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/dep-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "tags", "target": "tag", "enforcement": "active"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/dep-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/dep-repo/vulnerability-alerts"), + httpmock.StatusStringResponse(404, "not found"), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/octocat/dep-repo.git", + }) + return &PublishOptions{ + IO: ios, + Dir: dir, + DryRun: true, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStderr: "Dependabot", + }, + { + name: "installed skill dirs not gitignored warns", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".agents", "skills", "installed"), 0o755)) + runGitInDir(t, dir, "init", "--initial-branch=main") + runGitInDir(t, dir, "config", "user.email", "monalisa@github.com") + runGitInDir(t, dir, "config", "user.name", "Monalisa Octocat") + + return &PublishOptions{ + IO: ios, + Dir: dir, + GitClient: &git.Client{RepoDir: dir}, + } + }, + wantStdout: ".gitignore", + }, + { + name: "installed skill dirs gitignored no warning", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".agents", "skills", "installed"), 0o755)) + + runGitInDir(t, dir, "init", "--initial-branch=main") + runGitInDir(t, dir, "config", "user.email", "monalisa@github.com") + runGitInDir(t, dir, "config", "user.name", "Monalisa Octocat") + require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(".agents/skills\n"), 0o644)) + runGitInDir(t, dir, "add", ".gitignore") + runGitInDir(t, dir, "commit", "-m", "init") + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{ + IO: ios, + Dir: dir, + GitClient: &git.Client{RepoDir: dir}, + } + }, + wantStdout: "no git remote", + verify: func(t *testing.T, dir string) { + t.Helper() + // The key assertion: .gitignored dirs should NOT produce a warning + }, + }, + { + name: "installed skill dirs git error warns about unverified status", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + // Create install dir but do NOT init git so check-ignore will fail + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".agents", "skills", "installed"), 0o755)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{ + IO: ios, + Dir: dir, + GitClient: &git.Client{RepoDir: dir}, + } + }, + wantStdout: "may contain installed skills that are not gitignored", + }, + { + name: "no GitHub remote warns", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + runGitInDir(t, dir, "init", "--initial-branch=main") + runGitInDir(t, dir, "config", "user.email", "monalisa@github.com") + runGitInDir(t, dir, "config", "user.name", "Monalisa Octocat") + runGitInDir(t, dir, "remote", "add", "origin", "https://gitlab.com/hubot/bar.git") + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{ + IO: ios, + Dir: dir, + GitClient: &git.Client{RepoDir: dir}, + } + }, + wantStdout: "not a GitHub repository", + }, + { + name: "fallback remote detection uses non-origin GitHub remote", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "octocat", "repo") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://gitlab.com/hubot/bar.git", + "upstream": "git@github.com:octocat/repo.git", + }) + return &PublishOptions{ + IO: ios, + Dir: dir, + DryRun: true, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStderr: "octocat/repo", + }, + { + name: "publish adds missing topic via --tag", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + // topic missing + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"golang"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/tags"), + httpmock.JSONResponse([]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "tags", "target": "tag", "enforcement": "active"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + // addAgentSkillsTopic fetches topics again then PUTs + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"golang"}, + }), + ) + reg.Register( + httpmock.REST("PUT", "repos/monalisa/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{}), + ) + // immutable releases + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": true}), + ) + // default branch + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"}), + ) + // create release + reg.Register( + httpmock.REST("POST", "repos/monalisa/skills-repo/releases"), + httpmock.JSONResponse(map[string]interface{}{ + "html_url": "https://github.com/monalisa/skills-repo/releases/tag/v1.0.0", + }), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) + return &PublishOptions{ + IO: ios, + Dir: dir, + Tag: "v1.0.0", + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, def bool) (bool, error) { return true, nil }, + }, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "Added \"agent-skills\" topic", + }, + { + name: "tag suggestion uses existing tags", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"agent-skills"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/tags"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "v2.3.4"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "tags", "target": "tag", "enforcement": "active"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + // immutable releases + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": true}), + ) + // default branch + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"}), + ) + // create release with the suggested v2.3.5 tag + reg.Register( + httpmock.REST("POST", "repos/monalisa/skills-repo/releases"), + httpmock.JSONResponse(map[string]interface{}{ + "html_url": "https://github.com/monalisa/skills-repo/releases/tag/v2.3.5", + }), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) + return &PublishOptions{ + IO: ios, + Dir: dir, + Tag: "v2.3.5", + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "Published v2.3.5", + }, + { + name: "duplicate tag errors", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) + return &PublishOptions{ + IO: ios, + Dir: dir, + Tag: "v1.0.0", // same as stubAllSecureRemote's existing tag + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantErr: "tag v1.0.0 already exists", + }, + { + name: "valid skill non-tty plain output", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "git-commit", heredoc.Doc(` + --- + name: git-commit + description: A skill for writing good git commits + allowed-tools: git + license: MIT + --- + You are a git commit expert. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) + return &PublishOptions{ + IO: ios, + Dir: dir, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "ok", + }, + { + name: "no remote and non-tty shows validation passed message", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{ + IO: ios, + Dir: dir, + } + }, + wantStdout: "ok", + }, + { + name: "interactive publish with topic and semver tag", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + // No topic yet, first GET for diagnostic check + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{"names": []string{}}), + ) + // Second GET inside addAgentSkillsTopic + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{"names": []string{}}), + ) + // Add topic + reg.Register( + httpmock.REST("PUT", "repos/monalisa/skills-repo/topics"), + httpmock.StringResponse("{}"), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/tags"), + httpmock.JSONResponse([]map[string]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "default_branch": "main", + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + // Immutable releases already enabled + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": true}), + ) + // Create release + reg.Register( + httpmock.REST("POST", "repos/monalisa/skills-repo/releases"), + httpmock.JSONResponse(map[string]interface{}{ + "html_url": "https://github.com/monalisa/skills-repo/releases/tag/v1.0.0", + }), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + confirmCall := 0 + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) + return &PublishOptions{ + IO: ios, + Dir: dir, + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, def bool) (bool, error) { + confirmCall++ + return true, nil // accept topic + final confirm + }, + SelectFunc: func(msg string, def string, opts []string) (int, error) { + return 0, nil // semver strategy + }, + InputFunc: func(msg string, def string) (string, error) { + return "v1.0.0", nil // accept suggested tag + }, + }, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "Published v1.0.0", + }, + { + name: "interactive publish with custom tag", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": true}), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"}), + ) + reg.Register( + httpmock.REST("POST", "repos/monalisa/skills-repo/releases"), + httpmock.JSONResponse(map[string]interface{}{ + "html_url": "https://github.com/monalisa/skills-repo/releases/tag/beta-1", + }), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) + return &PublishOptions{ + IO: ios, + Dir: dir, + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, def bool) (bool, error) { + return true, nil + }, + SelectFunc: func(msg string, def string, opts []string) (int, error) { + return 1, nil // custom tag strategy + }, + InputFunc: func(msg string, def string) (string, error) { + return "beta-1", nil + }, + }, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "Published beta-1", + }, + { + name: "interactive publish declined at final confirm", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": true}), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"}), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + confirmCall := 0 + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) + return &PublishOptions{ + IO: ios, + Dir: dir, + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, def bool) (bool, error) { + confirmCall++ + if confirmCall >= 1 { + return false, nil // decline final confirm + } + return true, nil + }, + SelectFunc: func(msg string, def string, opts []string) (int, error) { + return 0, nil + }, + InputFunc: func(msg string, def string) (string, error) { + return "v1.0.1", nil + }, + }, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantErr: "CancelError", + wantStderr: "Publish cancelled", + }, + { + name: "interactive immutable releases prompt", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + // Immutable releases NOT enabled + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": false}), + ) + // Enable immutable releases + reg.Register( + httpmock.REST("PATCH", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.StringResponse("{}"), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"}), + ) + reg.Register( + httpmock.REST("POST", "repos/monalisa/skills-repo/releases"), + httpmock.JSONResponse(map[string]interface{}{ + "html_url": "https://github.com/monalisa/skills-repo/releases/tag/v1.0.1", + }), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) + return &PublishOptions{ + IO: ios, + Dir: dir, + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, def bool) (bool, error) { + return true, nil // accept all confirms (immutable + final) + }, + SelectFunc: func(msg string, def string, opts []string) (int, error) { + return 0, nil + }, + InputFunc: func(msg string, def string) (string, error) { + return "v1.0.1", nil + }, + }, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "Enabled immutable releases", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + tt.setup(t, dir) + + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(tt.isTTY) + ios.SetStdinTTY(tt.isTTY) + ios.SetStderrTTY(tt.isTTY) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + if tt.stubs != nil { + tt.stubs(reg) + } + + opts := tt.opts(ios, dir, reg) + err := publishRun(opts) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + } + if tt.wantStdout != "" { + assert.Contains(t, stdout.String(), tt.wantStdout) + } + if tt.wantStderr != "" { + assert.Contains(t, stderr.String(), tt.wantStderr) + } + if tt.verify != nil { + tt.verify(t, dir) + } + }) + } +} + +func TestDetectGitHubRemote_UsesDir(t *testing.T) { + // Create two separate git repos: "cwd-repo" simulates the working directory + // and "target-repo" simulates the directory argument passed to publish. + cwdRepo := t.TempDir() + initGitRepo(t, cwdRepo, map[string]string{ + "origin": "https://github.com/monalisa/cwd-repo.git", + }) + + targetRepo := t.TempDir() + initGitRepo(t, targetRepo, map[string]string{ + "origin": "https://github.com/monalisa/target-repo.git", + }) + + // gitClient points at cwd-repo (simulating factory-provided client) + gitClient := &git.Client{RepoDir: cwdRepo} + + // detectGitHubRemote should use targetRepo's remotes, not cwdRepo's + repo, err := detectGitHubRemote(gitClient, targetRepo) + require.NoError(t, err) + require.NotNil(t, repo) + assert.Equal(t, "monalisa", repo.Repo.RepoOwner()) + assert.Equal(t, "target-repo", repo.Repo.RepoName()) +} + +func TestPublishRun_DirArgUsesTargetRemote(t *testing.T) { + // Regression test: when a directory argument is provided, remote detection + // must use that directory's git remotes, not the factory client's directory. + // + // Scenario: + // 1. User is in cwd-repo (has remote → monalisa/cwd-repo) + // 2. User runs: gh skill publish /path/to/target-repo + // 3. target-repo has remote → monalisa/target-repo + // 4. API calls must go to target-repo, NOT cwd-repo + + cwdRepo := t.TempDir() + initGitRepo(t, cwdRepo, map[string]string{ + "origin": "https://github.com/monalisa/cwd-repo.git", + }) + + targetRepo := t.TempDir() + initGitRepo(t, targetRepo, map[string]string{ + "origin": "https://github.com/monalisa/target-repo.git", + }) + + writeSkill(t, targetRepo, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A test skill + license: MIT + --- + Body text. + `)) + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + + // Stub API calls for target-repo (the correct repo). + // If the bug is present, these stubs won't be called because the code + // would try to hit cwd-repo endpoints instead, and reg.Verify would fail. + stubAllSecureRemote(reg, "monalisa", "target-repo") + + err := publishRun(&PublishOptions{ + IO: ios, + Dir: targetRepo, + DryRun: true, + GitClient: &git.Client{RepoDir: cwdRepo}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + }) + + require.NoError(t, err) + assert.Contains(t, stdout.String(), "1 skill(s) validated successfully") +} + +// writeSkill creates skills//SKILL.md with the given content. +func writeSkill(t *testing.T, dir, name, content string) { + t.Helper() + skillDir := filepath.Join(dir, "skills", name) + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) +} + +// runGitInDir runs a git command in the given directory with isolation env vars. +func runGitInDir(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+dir) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, out) +} + +// newTestGitClientWithUpstream creates a git repo with a local bare "remote" +// and an initial commit, so we can test push/rev-list behavior realistically. +// It returns the git client and the working directory path. +func newTestGitClientWithUpstream(t *testing.T) (*git.Client, string) { + t.Helper() + parentDir := t.TempDir() + bareDir := filepath.Join(parentDir, "upstream.git") + workDir := filepath.Join(parentDir, "work") + + gitEnv := append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+parentDir) + + run := func(dir string, args ...string) { + t.Helper() + c := exec.Command("git", append([]string{"-C", dir}, args...)...) + c.Env = gitEnv + out, err := c.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, out) + } + + // Create bare upstream + require.NoError(t, os.MkdirAll(bareDir, 0o755)) + run(bareDir, "init", "--bare", "--initial-branch=main") + + // Clone into working dir + c := exec.Command("git", "clone", bareDir, workDir) + c.Env = gitEnv + out, err := c.CombinedOutput() + require.NoError(t, err, "git clone: %s", out) + + run(workDir, "config", "user.email", "monalisa@github.com") + run(workDir, "config", "user.name", "Monalisa Octocat") + + // Create initial commit and push + require.NoError(t, os.WriteFile(filepath.Join(workDir, "README.md"), []byte("# Test"), 0o644)) + run(workDir, "add", ".") + run(workDir, "commit", "-m", "initial commit") + run(workDir, "push", "origin", "main") + + return &git.Client{ + RepoDir: workDir, + GitPath: "git", + Stderr: &bytes.Buffer{}, + Stdin: &bytes.Buffer{}, + Stdout: &bytes.Buffer{}, + }, workDir +} + +func TestEnsurePushed(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, workDir string) + verify func(t *testing.T, workDir string) + wantErr string + wantStderr string + }{ + { + name: "no unpushed commits is a no-op", + setup: func(_ *testing.T, _ string) { + // initial commit already pushed by helper + }, + }, + { + name: "unpushed commits are pushed automatically", + setup: func(t *testing.T, workDir string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(workDir, "new.txt"), []byte("new"), 0o644)) + runGitInDir(t, workDir, "add", ".") + runGitInDir(t, workDir, "commit", "-m", "unpushed change") + }, + verify: func(t *testing.T, workDir string) { + t.Helper() + // After push, rev-list should show 0 unpushed commits + cmd := exec.Command("git", "-C", workDir, "rev-list", "--count", "@{push}..HEAD") + cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+workDir) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "rev-list: %s", out) + assert.Equal(t, "0", strings.TrimSpace(string(out))) + }, + wantStderr: "Pushing main to origin", + }, + { + name: "new branch never pushed is pushed automatically", + setup: func(t *testing.T, workDir string) { + t.Helper() + runGitInDir(t, workDir, "checkout", "-b", "feature") + require.NoError(t, os.WriteFile(filepath.Join(workDir, "feat.txt"), []byte("feat"), 0o644)) + runGitInDir(t, workDir, "add", ".") + runGitInDir(t, workDir, "commit", "-m", "new branch commit") + }, + verify: func(t *testing.T, workDir string) { + t.Helper() + // After push, the branch should exist on the remote + cmd := exec.Command("git", "-C", workDir, "rev-list", "--count", "@{push}..HEAD") + cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+workDir) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "rev-list: %s", out) + assert.Equal(t, "0", strings.TrimSpace(string(out))) + }, + wantStderr: "Pushing feature to origin", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gitClient, workDir := newTestGitClientWithUpstream(t) + tt.setup(t, workDir) + + ios, _, _, stderr := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + opts := &PublishOptions{ + IO: ios, + GitClient: gitClient, + } + + err := ensurePushed(opts, workDir, "origin") + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + } + if tt.wantStderr != "" { + assert.Contains(t, stderr.String(), tt.wantStderr) + } + if tt.verify != nil { + tt.verify(t, workDir) + } + }) + } +} diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go new file mode 100644 index 000000000..f7d4975a7 --- /dev/null +++ b/pkg/cmd/skills/search/search.go @@ -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 [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: and the implicit owner search is skipped. +func searchByKeyword(client *api.Client, host, queryTerm, owner string, page, limit int) ([]skillResult, error) { + ownerScope := "" + if owner != "" { + ownerScope = " user:" + owner + } + + primaryQ := fmt.Sprintf("filename:SKILL.md %s%s", queryTerm, ownerScope) + pathTerm := strings.ReplaceAll(queryTerm, " ", "-") + pathQ := fmt.Sprintf("filename:SKILL.md path:%s%s", pathTerm, ownerScope) + + var ( + primaryItems []codeSearchItem + primaryErr error + pathResult *codeSearchResult + pathErr error + ownerResult *codeSearchResult + ownerErr error + hyphenResult *codeSearchResult + hyphenErr error + ) + + hasSpaces := strings.Contains(queryTerm, " ") + + var wg sync.WaitGroup + + wg.Add(1) + go func() { + defer wg.Done() + pathResult, pathErr = executeSearch(client, host, pathQ, 1, searchPageSize) + }() + + // When no explicit --owner is set and the query looks like it could be a + // GitHub username, fire an additional user: search to discover + // skills published by that org. Results compete on the same footing as + // everything else (no scoring boost). + if owner == "" && couldBeOwner(queryTerm) { + ownerQ := fmt.Sprintf("filename:SKILL.md user:%s", queryTerm) + wg.Add(1) + go func() { + defer wg.Done() + ownerResult, ownerErr = executeSearch(client, host, ownerQ, 1, searchPageSize) + }() + } + + // When the query has spaces (e.g. "mcp apps"), run an additional content + // search with the hyphenated form ("mcp-apps") so we don't miss skills + // whose names use hyphens as word separators. + if hasSpaces { + hyphenQ := fmt.Sprintf("filename:SKILL.md %s%s", pathTerm, ownerScope) + wg.Add(1) + go func() { + defer wg.Done() + hyphenResult, hyphenErr = executeSearch(client, host, hyphenQ, 1, searchPageSize) + }() + } + + // Primary content search runs on the main goroutine. + primaryItems, _, primaryErr = fetchPrimaryPages(client, host, primaryQ, page, limit) + wg.Wait() + + if primaryErr != nil { + return nil, primaryErr + } + + // Merge: path-matched > hyphen-matched > owner-matched > primary content. + var merged []codeSearchItem + + if pathErr == nil && pathResult != nil { + merged = append(merged, pathResult.Items...) + } + if hasSpaces && hyphenErr == nil && hyphenResult != nil { + merged = append(merged, hyphenResult.Items...) + } + if ownerErr == nil && ownerResult != nil { + merged = append(merged, ownerResult.Items...) + } + merged = append(merged, primaryItems...) + + return deduplicateResults(merged), nil +} + +// noResults returns an empty JSON array for exporters or a no-results error. +func noResults(opts *SearchOptions, msg string) error { + if opts.Exporter != nil { + return opts.Exporter.Write(opts.IO, []skillResult{}) + } + return cmdutil.NewNoResultsError(msg) +} + +// truncateForProcessing caps the working set before expensive enrichment. +// Each skill in the working set triggers a blob fetch (description) and +// potentially a repo fetch (stars), so keeping this small matters for +// performance. Pre-ranking ensures the best candidates are at the top. +func truncateForProcessing(skills []skillResult, page, limit int) []skillResult { + maxToProcess := page * limit * 3 + if maxToProcess < limit*3 { + maxToProcess = limit * 3 + } + if len(skills) > maxToProcess { + return skills[:maxToProcess] + } + return skills +} + +// enrichSkills fetches descriptions and star counts concurrently. +// 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 +} diff --git a/pkg/cmd/skills/search/search_test.go b/pkg/cmd/skills/search/search_test.go new file mode 100644 index 000000000..bdfe3ba19 --- /dev/null +++ b/pkg/cmd/skills/search/search_test.go @@ -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) + } +} diff --git a/pkg/cmd/skills/skills.go b/pkg/cmd/skills/skills.go new file mode 100644 index 000000000..1dadd3b1f --- /dev/null +++ b/pkg/cmd/skills/skills.go @@ -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 ", + 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 +} diff --git a/pkg/cmd/skills/update/update.go b/pkg/cmd/skills/update/update.go new file mode 100644 index 000000000..7923a6bde --- /dev/null +++ b/pkg/cmd/skills/update/update.go @@ -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 [...] [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 +} diff --git a/pkg/cmd/skills/update/update_test.go b/pkg/cmd/skills/update/update_test.go new file mode 100644 index 000000000..86fdcaa80 --- /dev/null +++ b/pkg/cmd/skills/update/update_test.go @@ -0,0 +1,1196 @@ +package update + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/skills/registry" + + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCmdUpdate_Help(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{}, + } + + cmd := NewCmdUpdate(f, func(opts *UpdateOptions) error { + return nil + }) + + assert.Equal(t, "update [...] [flags]", cmd.Use) + assert.NotEmpty(t, cmd.Short) + assert.NotEmpty(t, cmd.Long) + assert.NotEmpty(t, cmd.Example) +} + +func TestNewCmdUpdate_Flags(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} + cmd := NewCmdUpdate(f, func(_ *UpdateOptions) error { return nil }) + + flags := []string{"all", "force", "dry-run", "dir", "unpin"} + for _, name := range flags { + assert.NotNil(t, cmd.Flags().Lookup(name), "missing flag: --%s", name) + } +} + +func TestNewCmdUpdate_ArgsPassedToOptions(t *testing.T) { + ios, _, stdout, stderr := iostreams.Test() + f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} + + var gotOpts *UpdateOptions + cmd := NewCmdUpdate(f, func(opts *UpdateOptions) error { + gotOpts = opts + return nil + }) + + args, _ := shlex.Split("mcp-cli git-commit --all --force") + cmd.SetArgs(args) + cmd.SetOut(stdout) + cmd.SetErr(stderr) + err := cmd.Execute() + require.NoError(t, err) + assert.Equal(t, []string{"mcp-cli", "git-commit"}, gotOpts.Skills) + assert.True(t, gotOpts.All) + assert.True(t, gotOpts.Force) +} + +func TestScanInstalledSkills(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, dir string) + verify func(t *testing.T, skills []installedSkill, err error) + }{ + { + name: "happy path with metadata, no metadata, and pinned skills", + setup: func(t *testing.T, dir string) { + t.Helper() + + // Skill with full metadata + skillDir := filepath.Join(dir, "git-commit") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + content := heredoc.Doc(` + --- + name: git-commit + description: Git commit helper + metadata: + github-repo: https://github.com/monalisa/awesome-copilot + github-tree-sha: abc123 + github-path: skills/git-commit + --- + Body content + `) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) + + // Skill without metadata + noMetaDir := filepath.Join(dir, "unknown-skill") + require.NoError(t, os.MkdirAll(noMetaDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(noMetaDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: unknown-skill + --- + No metadata here + `)), 0o644)) + + // Pinned skill + pinnedDir := filepath.Join(dir, "pinned-skill") + require.NoError(t, os.MkdirAll(pinnedDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(pinnedDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: pinned-skill + metadata: + github-repo: https://github.com/octocat/hubot-skills + github-tree-sha: def456 + github-pinned: v1.0.0 + --- + Pinned content + `)), 0o644)) + }, + verify: func(t *testing.T, skills []installedSkill, err error) { + t.Helper() + require.NoError(t, err) + assert.Len(t, skills, 3) + + byName := make(map[string]installedSkill) + for _, s := range skills { + byName[s.name] = s + } + + gc := byName["git-commit"] + assert.Equal(t, "monalisa", gc.owner) + assert.Equal(t, "awesome-copilot", gc.repo) + assert.Equal(t, "github.com", gc.repoHost) + assert.Equal(t, "abc123", gc.treeSHA) + assert.Equal(t, "skills/git-commit", gc.sourcePath) + assert.Empty(t, gc.pinned) + + us := byName["unknown-skill"] + assert.Empty(t, us.owner) + assert.Empty(t, us.repo) + + ps := byName["pinned-skill"] + assert.Equal(t, "github.com", ps.repoHost) + assert.Equal(t, "v1.0.0", ps.pinned) + }, + }, + { + name: "unsupported host metadata returns error", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "enterprise-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: enterprise-skill + metadata: + github-repo: https://acme.ghes.com/monalisa/octocat-skills + github-tree-sha: abc123 + --- + body + `)), 0o644)) + }, + verify: func(t *testing.T, skills []installedSkill, err error) { + t.Helper() + require.NoError(t, err) + require.Len(t, skills, 1) + require.Error(t, skills[0].metadataErr) + assert.Contains(t, skills[0].metadataErr.Error(), "supports only github.com") + }, + }, + { + name: "non-existent directory returns nil", + // no setup needed; dir does not exist + verify: func(t *testing.T, skills []installedSkill, err error) { + t.Helper() + require.NoError(t, err) + assert.Nil(t, skills) + }, + }, + { + name: "corrupted YAML is skipped gracefully", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "corrupt") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + not: valid: yaml: [broken + --- + body + `)), 0o644)) + }, + verify: func(t *testing.T, skills []installedSkill, err error) { + t.Helper() + require.NoError(t, err) + require.Len(t, skills, 1) + assert.Equal(t, "corrupt", skills[0].name) + assert.ErrorContains(t, skills[0].metadataErr, "invalid SKILL.md") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // For the non-existent directory case, pass a path that doesn't exist + dir := filepath.Join(t.TempDir(), "skills") + if tt.setup != nil { + require.NoError(t, os.MkdirAll(dir, 0o755)) + tt.setup(t, dir) + } + + skills, err := scanInstalledSkills(dir, nil, "") + tt.verify(t, skills, err) + }) + } +} + +func TestPromptForSkillOrigin(t *testing.T) { + tests := []struct { + name string + input string + wantOK bool + wantOwner string + wantRepo string + wantReason string + }{ + { + name: "valid owner/repo", + input: "monalisa/awesome-copilot", + wantOK: true, + wantOwner: "monalisa", + wantRepo: "awesome-copilot", + }, + { + name: "empty input skips", + input: "", + wantOK: false, + }, + { + name: "invalid format returns reason", + input: "just-a-name", + wantOK: false, + wantReason: "invalid repository", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pm := &prompter.PrompterMock{ + InputFunc: func(prompt string, defaultValue string) (string, error) { + return tt.input, nil + }, + } + + owner, repo, reason, ok, err := promptForSkillOrigin(pm, "test-skill") + require.NoError(t, err) + assert.Equal(t, tt.wantOK, ok) + assert.Equal(t, tt.wantOwner, owner) + assert.Equal(t, tt.wantRepo, repo) + if tt.wantReason != "" { + assert.Contains(t, reason, tt.wantReason) + } + }) + } +} + +func TestScanAllAgentsDeduplicatesSharedProjectDirs(t *testing.T) { + repoDir := t.TempDir() + homeDir := t.TempDir() + + sharedSkillDir := filepath.Join(repoDir, ".agents", "skills", "git-commit") + require.NoError(t, os.MkdirAll(sharedSkillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(sharedSkillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: git-commit + metadata: + github-repo: https://github.com/monalisa/octocat-skills + github-tree-sha: abc123 + --- + Body + `)), 0o644)) + + claudeSkillDir := filepath.Join(repoDir, ".claude", "skills", "code-review") + require.NoError(t, os.MkdirAll(claudeSkillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(claudeSkillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-repo: https://github.com/monalisa/octocat-skills + github-tree-sha: def456 + --- + Body + `)), 0o644)) + + skills := scanAllAgents(repoDir, homeDir) + require.Len(t, skills, 2) + + byName := make(map[string]installedSkill) + for _, skill := range skills { + byName[skill.name] = skill + } + + assert.Equal(t, registry.ScopeProject, byName["git-commit"].scope) + assert.Equal(t, registry.ScopeProject, byName["code-review"].scope) +} + +func TestUpdateRun(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, dir string) + stubs func(reg *httpmock.Registry) + opts func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions + verify func(t *testing.T, dir string) + wantErr string + wantStderr string + wantStdout string + }{ + { + name: "scans all agents when no --dir is set", + setup: func(t *testing.T, dir string) { + t.Helper() + t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) + skillDir := filepath.Join(dir, ".agents", "skills", "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-repo: https://github.com/monalisa/octocat-skills + github-tree-sha: currentsha + github-path: skills/code-review + --- + Installed content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "commit1", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/commit1"), + httpmock.StringResponse(`{"sha": "commit1", "tree": [{"path": "skills/code-review", "type": "tree", "sha": "currentsha"}, {"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "blob1"}, {"path": "skills", "type": "tree", "sha": "treeshaX"}], "truncated": false}`)) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(false) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + } + }, + wantStderr: "All skills are up to date.", + }, + { + name: "no installed skills", + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(false) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantStderr: "No installed skills found.", + }, + { + name: "specific skill not installed", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "octocat-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: octocat-skill + metadata: + github-repo: https://github.com/octocat/hubot-skills + github-tree-sha: abc + --- + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(false) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + Skills: []string{"nonexistent"}, + } + }, + wantErr: "none of the specified skills are installed", + }, + { + name: "pinned skills are skipped", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "pinned-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: pinned-skill + metadata: + github-repo: https://github.com/octocat/hubot-skills + github-tree-sha: abc123 + github-pinned: v1.0.0 + --- + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantStderr: "pinned", + }, + { + name: "no metadata skips in non-interactive mode", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "manual-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: manual-skill + --- + No metadata + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantStderr: "no GitHub metadata", + }, + { + name: "all up to date", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "monalisa-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: monalisa-skill + metadata: + github-repo: https://github.com/monalisa/octocat-skills + github-tree-sha: abc123def456 + github-path: skills/monalisa-skill + --- + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "commitsha123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/commitsha123"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "commitsha123", "tree": [{"path": "skills/monalisa-skill/SKILL.md", "type": "blob", "sha": "blobsha1"}, {"path": "skills/monalisa-skill", "type": "tree", "sha": "abc123def456"}, {"path": "skills", "type": "tree", "sha": "treeshaX"}], "truncated": false}`)), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(false) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantStderr: "All skills are up to date.", + }, + { + name: "dry run reports available updates", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "hubot-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: hubot-skill + metadata: + github-repo: https://github.com/hubot/octocat-skills + github-tree-sha: oldsha123 + github-path: skills/hubot-skill + --- + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/hubot/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v2.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/hubot/octocat-skills/git/ref/tags/v2.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit456", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/hubot/octocat-skills/git/trees/newcommit456"), + httpmock.StringResponse(`{"sha": "newcommit456", "tree": [{"path": "skills/hubot-skill/SKILL.md", "type": "blob", "sha": "blobsha2"}, {"path": "skills/hubot-skill", "type": "tree", "sha": "newsha456"}, {"path": "skills", "type": "tree", "sha": "treeshaY"}], "truncated": false}`), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + DryRun: true, + } + }, + wantStderr: "1 update(s) available:", + wantStdout: "hubot-skill", + }, + { + name: "non-interactive without --all errors", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "hubot-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: hubot-skill + metadata: + github-repo: https://github.com/hubot/octocat-skills + github-tree-sha: oldsha123 + github-path: skills/hubot-skill + --- + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/hubot/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v2.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/hubot/octocat-skills/git/ref/tags/v2.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit456", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/hubot/octocat-skills/git/trees/newcommit456"), + httpmock.StringResponse(`{"sha": "newcommit456", "tree": [{"path": "skills/hubot-skill/SKILL.md", "type": "blob", "sha": "blobsha2"}, {"path": "skills/hubot-skill", "type": "tree", "sha": "newsha456"}, {"path": "skills", "type": "tree", "sha": "treeshaY"}], "truncated": false}`), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantErr: "updates available; re-run with --all to apply, or run interactively to confirm", + }, + { + name: "force update rewrites SKILL.md on disk", + setup: func(t *testing.T, dir string) { + t.Helper() + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + skillDir := filepath.Join(dir, "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-repo: https://github.com/monalisa/octocat-skills + github-tree-sha: oldsha000 + github-path: skills/code-review + --- + Old content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newsha999"), + httpmock.StringResponse(`{"sha": "newsha999", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "newblob1", "size": 20}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/newblob1"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, + "IyBDb2RlIFJldmlldyBVcGRhdGVk"))) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(false) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + All: true, + Force: true, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "code-review", "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(content), "github-repo: https://github.com/monalisa/octocat-skills") + assert.NotContains(t, string(content), "Old content") + }, + wantStdout: "Updated code-review", + }, + { + name: "namespaced skill with --dir resolves install base correctly", + setup: func(t *testing.T, dir string) { + t.Helper() + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + skillDir := filepath.Join(dir, "monalisa", "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-repo: https://github.com/monalisa/octocat-skills + github-tree-sha: oldsha000 + github-path: skills/monalisa/code-review + --- + Old namespaced content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/monalisa/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/monalisa/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills/monalisa", "type": "tree", "sha": "nstresha"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newsha999"), + httpmock.StringResponse(`{"sha": "newsha999", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "newblob1", "size": 20}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/newblob1"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, + "IyBOYW1lc3BhY2VkIFNraWxsIFVwZGF0ZWQ="))) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(false) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + All: true, + Force: true, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "monalisa", "code-review", "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(content), "github-repo: https://github.com/monalisa/octocat-skills") + assert.NotContains(t, string(content), "Old namespaced content") + }, + wantStdout: "Updated monalisa/code-review", + }, + { + name: "install failure during update reports error and continues", + setup: func(t *testing.T, dir string) { + t.Helper() + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + skillDir := filepath.Join(dir, "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-repo: https://github.com/monalisa/octocat-skills + github-tree-sha: oldsha000 + github-path: skills/code-review + --- + Original content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newsha999"), + httpmock.StringResponse(`{"sha": "newsha999", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "newblob1", "size": 20}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/newblob1"), + httpmock.StatusStringResponse(500, "server error")) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(false) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + All: true, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "code-review", "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(content), "Original content", "file should not be modified on failure") + }, + wantStderr: "Failed to update code-review", + wantErr: "SilentError", + }, + { + name: "interactive confirm applies update", + setup: func(t *testing.T, dir string) { + t.Helper() + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + skillDir := filepath.Join(dir, "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-repo: https://github.com/monalisa/octocat-skills + github-tree-sha: oldsha000 + github-path: skills/code-review + --- + Old content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newsha999"), + httpmock.StringResponse(`{"sha": "newsha999", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "newblob1", "size": 20}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/newblob1"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, + "IyBDb2RlIFJldmlldyBVcGRhdGVk"))) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, defaultVal bool) (bool, error) { + return true, nil + }, + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "code-review", "SKILL.md")) + require.NoError(t, err) + assert.NotContains(t, string(content), "Old content") + }, + wantStdout: "Updated code-review", + }, + { + name: "interactive confirm cancelled", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-repo: https://github.com/monalisa/octocat-skills + github-tree-sha: oldsha000 + github-path: skills/code-review + --- + Old content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, defaultVal bool) (bool, error) { + return false, nil + }, + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantErr: "CancelError", + wantStderr: "Update cancelled", + }, + { + name: "no-metadata skill prompted interactively and skipped", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "manual-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: manual-skill + --- + No metadata + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{ + InputFunc: func(prompt string, defaultValue string) (string, error) { + return "", nil + }, + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantStderr: "no GitHub metadata", + }, + { + name: "no-metadata skill enriched via prompt then updated", + setup: func(t *testing.T, dir string) { + t.Helper() + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + skillDir := filepath.Join(dir, "manual-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: manual-skill + --- + Old manual content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "commit123", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/commit123"), + httpmock.StringResponse(`{"sha": "commit123", "tree": [{"path": "skills/manual-skill/SKILL.md", "type": "blob", "sha": "blob1"}, {"path": "skills/manual-skill", "type": "tree", "sha": "newtree1"}, {"path": "skills", "type": "tree", "sha": "treeshaX"}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newtree1"), + httpmock.StringResponse(`{"sha": "newtree1", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "blob1", "size": 20}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob1"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blob1", "encoding": "base64", "content": "%s"}`, + "IyBNYW51YWwgU2tpbGwgVXBkYXRlZA=="))) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{ + InputFunc: func(prompt string, defaultValue string) (string, error) { + return "monalisa/octocat-skills", nil + }, + ConfirmFunc: func(msg string, defaultVal bool) (bool, error) { + return true, nil + }, + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "manual-skill", "SKILL.md")) + require.NoError(t, err) + assert.NotContains(t, string(content), "Old manual content") + assert.Contains(t, string(content), "github-repo: https://github.com/monalisa/octocat-skills") + }, + wantStdout: "Updated manual-skill", + }, + { + name: "unpin clears pin and applies update", + setup: func(t *testing.T, dir string) { + t.Helper() + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + skillDir := filepath.Join(dir, "pinned-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: pinned-skill + metadata: + github-repo: https://github.com/octocat/hubot-skills + github-tree-sha: oldsha000 + github-pinned: v1.0.0 + github-path: skills/pinned-skill + --- + Pinned content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v2.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/git/ref/tags/v2.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/pinned-skill/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/pinned-skill", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/git/trees/newsha999"), + httpmock.StringResponse(`{"sha": "newsha999", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "newblob1", "size": 20}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/git/blobs/newblob1"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, + "IyBVbnBpbm5lZCBhbmQgVXBkYXRlZA=="))) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(false) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + All: true, + Unpin: true, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "pinned-skill", "SKILL.md")) + require.NoError(t, err) + assert.NotContains(t, string(content), "Pinned content") + assert.NotContains(t, string(content), "github-pinned") + }, + wantStdout: "Updated pinned-skill", + }, + { + name: "pinned skills still skipped without --unpin", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "pinned-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: pinned-skill + metadata: + github-repo: https://github.com/octocat/hubot-skills + github-tree-sha: abc123 + github-pinned: v1.0.0 + --- + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + Unpin: false, + } + }, + wantStderr: "pinned", + }, + { + name: "unpin with dry-run reports update without modifying files", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "pinned-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: pinned-skill + metadata: + github-repo: https://github.com/octocat/hubot-skills + github-tree-sha: oldsha000 + github-pinned: v1.0.0 + github-path: skills/pinned-skill + --- + Pinned content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v2.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/git/ref/tags/v2.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/pinned-skill/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/pinned-skill", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + DryRun: true, + Unpin: true, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "pinned-skill", "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(content), "github-pinned: v1.0.0", "dry-run should not modify files") + }, + wantStderr: "1 update(s) available:", + wantStdout: "pinned-skill", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, stdout, stderr := iostreams.Test() + + dir := t.TempDir() + if tt.setup != nil { + tt.setup(t, dir) + } + + reg := &httpmock.Registry{} + defer reg.Verify(t) + if tt.stubs != nil { + tt.stubs(reg) + } + + opts := tt.opts(ios, dir, reg) + err := updateRun(opts) + + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + return + } + require.NoError(t, err) + if tt.wantStderr != "" { + assert.Contains(t, stderr.String(), tt.wantStderr) + } + if tt.wantStdout != "" { + assert.Contains(t, stdout.String(), tt.wantStdout) + } + if tt.verify != nil { + tt.verify(t, dir) + } + }) + } +}