clean up interface and fix a few bugs

support specifying a sha in gh skills preview command
This commit is contained in:
tommaso-moro 2026-04-08 16:38:38 +01:00 committed by Sam Morrow
parent 663df07fcf
commit 1f5a6b8396
No known key found for this signature in database
11 changed files with 522 additions and 169 deletions

View file

@ -2,8 +2,10 @@ package discovery
import (
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"os"
"path"
"path/filepath"
@ -68,10 +70,27 @@ func (s Skill) InstallName() string {
// ResolvedRef contains the resolved git reference and its SHA.
type ResolvedRef struct {
Ref string // tag name, branch name, or SHA
Ref string // fully qualified ref (refs/heads/*, refs/tags/*) or commit SHA
SHA string // commit SHA
}
// IsFullyQualifiedRef returns true if ref uses the "refs/heads/" or "refs/tags/" prefix.
func IsFullyQualifiedRef(ref string) bool {
return strings.HasPrefix(ref, "refs/heads/") || strings.HasPrefix(ref, "refs/tags/")
}
// ShortRef strips the "refs/heads/" or "refs/tags/" prefix from a fully qualified ref,
// returning the short name. If the ref is not fully qualified it is returned as-is.
func ShortRef(ref string) string {
if after, ok := strings.CutPrefix(ref, "refs/heads/"); ok {
return after
}
if after, ok := strings.CutPrefix(ref, "refs/tags/"); ok {
return after
}
return ref
}
type treeEntry struct {
Path string `json:"path"`
Mode string `json:"mode"`
@ -117,35 +136,41 @@ func ResolveRef(client *api.Client, host, owner, repo, version string) (*Resolve
if err == nil {
return ref, nil
}
// Only fall back to the default branch when the repository genuinely
// has no releases (404) or the latest release has no tag. Any other
// API error (403, 500, network failure, …) is surfaced immediately
// so it cannot silently mask problems and cause an unexpected ref to
// be used.
var nre *noReleasesError
if !errors.As(err, &nre) {
return nil, err
}
return resolveDefaultBranch(client, host, owner, repo)
}
// resolveExplicitRef resolves a user-supplied --pin value. It tries, in order:
// tag → commit SHA. Branches are deliberately excluded because they are mutable
// and pinning to one gives a false sense of reproducibility.
// resolveExplicitRef resolves a user-supplied version string. It supports:
// - fully qualified refs: "refs/tags/v1.0" or "refs/heads/main"
// - short names: tried as branch first, then tag, then commit SHA
// - bare SHAs: resolved as commit SHA
//
// When a short name matches both a branch and a tag, the branch wins.
// The returned Ref is always a fully qualified ref (refs/heads/* or refs/tags/*)
// unless the input resolves to a bare commit SHA.
func resolveExplicitRef(client *api.Client, host, owner, repo, ref string) (*ResolvedRef, error) {
tagPath := fmt.Sprintf("repos/%s/%s/git/ref/tags/%s", owner, repo, ref)
var refResp struct {
Object struct {
SHA string `json:"sha"`
Type string `json:"type"`
} `json:"object"`
// Handle fully-qualified refs: resolve directly without ambiguity.
if after, ok := strings.CutPrefix(ref, "refs/tags/"); ok {
return resolveTagRef(client, host, owner, repo, after)
}
if err := client.REST(host, "GET", tagPath, nil, &refResp); err == nil {
sha := refResp.Object.SHA
if refResp.Object.Type == "tag" {
derefPath := fmt.Sprintf("repos/%s/%s/git/tags/%s", owner, repo, sha)
var tagResp struct {
Object struct {
SHA string `json:"sha"`
} `json:"object"`
}
if err := client.REST(host, "GET", derefPath, nil, &tagResp); err != nil {
return nil, fmt.Errorf("could not dereference annotated tag %q: %w", ref, err)
}
sha = tagResp.Object.SHA
}
return &ResolvedRef{Ref: ref, SHA: sha}, nil
if after, ok := strings.CutPrefix(ref, "refs/heads/"); ok {
return resolveBranchRef(client, host, owner, repo, after)
}
// Short name: try branch first, then tag, then commit SHA.
if resolved, err := resolveBranchRef(client, host, owner, repo, ref); err == nil {
return resolved, nil
}
if resolved, err := resolveTagRef(client, host, owner, repo, ref); err == nil {
return resolved, nil
}
commitPath := fmt.Sprintf("repos/%s/%s/commits/%s", owner, repo, ref)
@ -156,19 +181,80 @@ func resolveExplicitRef(client *api.Client, host, owner, repo, ref string) (*Res
return &ResolvedRef{Ref: commitResp.SHA, SHA: commitResp.SHA}, nil
}
return nil, fmt.Errorf("ref %q not found as tag or commit in %s/%s", ref, owner, repo)
return nil, fmt.Errorf("ref %q not found as branch, tag, or commit in %s/%s", ref, owner, repo)
}
// resolveTagRef looks up a tag by short name and returns a fully qualified ref.
// For annotated tags, the tag object is dereferenced to obtain the commit SHA.
func resolveTagRef(client *api.Client, host, owner, repo, tag string) (*ResolvedRef, error) {
tagPath := fmt.Sprintf("repos/%s/%s/git/ref/tags/%s", owner, repo, tag)
var refResp struct {
Object struct {
SHA string `json:"sha"`
Type string `json:"type"`
} `json:"object"`
}
if err := client.REST(host, "GET", tagPath, nil, &refResp); err != nil {
return nil, fmt.Errorf("tag %q not found in %s/%s: %w", tag, owner, repo, err)
}
sha := refResp.Object.SHA
if refResp.Object.Type == "tag" {
derefPath := fmt.Sprintf("repos/%s/%s/git/tags/%s", owner, repo, sha)
var tagResp struct {
Object struct {
SHA string `json:"sha"`
} `json:"object"`
}
if err := client.REST(host, "GET", derefPath, nil, &tagResp); err != nil {
return nil, fmt.Errorf("could not dereference annotated tag %q: %w", tag, err)
}
sha = tagResp.Object.SHA
}
return &ResolvedRef{Ref: "refs/tags/" + tag, SHA: sha}, nil
}
// resolveBranchRef looks up a branch by short name and returns a fully qualified ref.
func resolveBranchRef(client *api.Client, host, owner, repo, branch string) (*ResolvedRef, error) {
refPath := fmt.Sprintf("repos/%s/%s/git/ref/heads/%s", owner, repo, branch)
var refResp struct {
Object struct {
SHA string `json:"sha"`
} `json:"object"`
}
if err := client.REST(host, "GET", refPath, nil, &refResp); err != nil {
return nil, fmt.Errorf("branch %q not found in %s/%s: %w", branch, owner, repo, err)
}
return &ResolvedRef{Ref: "refs/heads/" + branch, SHA: refResp.Object.SHA}, nil
}
// noReleasesError signals that the repository has no usable releases,
// which is the only case where ResolveRef should fall back to the
// default branch.
type noReleasesError struct {
reason string
}
func (e *noReleasesError) Error() string { return e.reason }
func resolveLatestRelease(client *api.Client, host, owner, repo string) (*ResolvedRef, error) {
apiPath := fmt.Sprintf("repos/%s/%s/releases/latest", owner, repo)
var release releaseResponse
if err := client.REST(host, "GET", apiPath, nil, &release); err != nil {
return nil, fmt.Errorf("no releases found: %w", err)
// A 404 means the repository has no releases — this is the
// only case where falling back to the default branch is safe.
// Any other HTTP error (403, 500, …) or network failure is
// returned as-is so ResolveRef surfaces it rather than
// silently falling back.
var httpErr api.HTTPError
if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound {
return nil, &noReleasesError{reason: fmt.Sprintf("no releases found for %s/%s", owner, repo)}
}
return nil, fmt.Errorf("could not fetch latest release: %w", err)
}
if release.TagName == "" {
return nil, fmt.Errorf("latest release has no tag")
return nil, &noReleasesError{reason: "latest release has no tag"}
}
return resolveExplicitRef(client, host, owner, repo, release.TagName)
return resolveTagRef(client, host, owner, repo, release.TagName)
}
func resolveDefaultBranch(client *api.Client, host, owner, repo string) (*ResolvedRef, error) {
@ -181,18 +267,7 @@ func resolveDefaultBranch(client *api.Client, host, owner, repo string) (*Resolv
if branch == "" {
branch = "main"
}
refPath := fmt.Sprintf("repos/%s/%s/git/ref/heads/%s", owner, repo, branch)
var refResp struct {
Object struct {
SHA string `json:"sha"`
} `json:"object"`
}
if err := client.REST(host, "GET", refPath, nil, &refResp); err != nil {
return nil, fmt.Errorf("could not resolve branch %q: %w", branch, err)
}
return &ResolvedRef{Ref: branch, SHA: refResp.Object.SHA}, nil
return resolveBranchRef(client, host, owner, repo, branch)
}
// skillMatch represents a matched SKILL.md file and its convention.
@ -267,7 +342,7 @@ func DiscoverSkills(client *api.Client, host, owner, repo, commitSHA string) ([]
if tree.Truncated {
return nil, fmt.Errorf(
"repository tree for %s/%s is too large for full discovery\n"+
" Use path-based install instead: gh skills install %s/%s skills/<skill-name>",
" Use path-based install instead: gh skill install %s/%s skills/<skill-name>",
owner, repo, owner, repo,
)
}

View file

@ -162,6 +162,45 @@ func TestIsSpecCompliant(t *testing.T) {
}
}
func TestIsFullyQualifiedRef(t *testing.T) {
tests := []struct {
name string
ref string
want bool
}{
{name: "branch ref", ref: "refs/heads/main", want: true},
{name: "tag ref", ref: "refs/tags/v1.0", want: true},
{name: "short branch name", ref: "main", want: false},
{name: "short tag name", ref: "v1.0", want: false},
{name: "bare SHA", ref: "abc123def456", want: false},
{name: "empty", ref: "", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, IsFullyQualifiedRef(tt.ref))
})
}
}
func TestShortRef(t *testing.T) {
tests := []struct {
name string
ref string
want string
}{
{name: "branch ref", ref: "refs/heads/main", want: "main"},
{name: "tag ref", ref: "refs/tags/v1.0", want: "v1.0"},
{name: "short name passthrough", ref: "main", want: "main"},
{name: "bare SHA passthrough", ref: "abc123", want: "abc123"},
{name: "empty passthrough", ref: "", want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, ShortRef(tt.ref))
})
}
}
func TestResolveRef(t *testing.T) {
tests := []struct {
name string
@ -172,22 +211,41 @@ func TestResolveRef(t *testing.T) {
wantErr string
}{
{
name: "explicit version resolves lightweight tag",
name: "short name resolves as branch first",
version: "main",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/main"),
httpmock.JSONResponse(map[string]interface{}{
"object": map[string]interface{}{"sha": "branch-sha"},
}))
},
wantRef: "refs/heads/main",
wantSHA: "branch-sha",
},
{
name: "short name falls back to tag when branch not found",
version: "v1.0",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/v1.0"),
httpmock.StatusStringResponse(404, "not found"))
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v1.0"),
httpmock.JSONResponse(map[string]interface{}{
"object": map[string]interface{}{"sha": "abc123", "type": "commit"},
}))
},
wantRef: "v1.0",
wantRef: "refs/tags/v1.0",
wantSHA: "abc123",
},
{
name: "explicit version resolves annotated tag",
name: "short name resolves annotated tag",
version: "v2.0",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/v2.0"),
httpmock.StatusStringResponse(404, "not found"))
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v2.0"),
httpmock.JSONResponse(map[string]interface{}{
@ -199,13 +257,16 @@ func TestResolveRef(t *testing.T) {
"object": map[string]interface{}{"sha": "real-commit-sha"},
}))
},
wantRef: "v2.0",
wantRef: "refs/tags/v2.0",
wantSHA: "real-commit-sha",
},
{
name: "explicit version falls back to commit SHA",
name: "short name falls back to commit SHA",
version: "deadbeef",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/deadbeef"),
httpmock.StatusStringResponse(404, "not found"))
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/deadbeef"),
httpmock.StatusStringResponse(404, "not found"))
@ -217,9 +278,12 @@ func TestResolveRef(t *testing.T) {
wantSHA: "deadbeef",
},
{
name: "explicit version not found anywhere",
name: "short name not found anywhere",
version: "nonexistent",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/nonexistent"),
httpmock.StatusStringResponse(404, "not found"))
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/nonexistent"),
httpmock.StatusStringResponse(404, "not found"))
@ -227,10 +291,70 @@ func TestResolveRef(t *testing.T) {
httpmock.REST("GET", "repos/monalisa/octocat-skills/commits/nonexistent"),
httpmock.StatusStringResponse(404, "not found"))
},
wantErr: `ref "nonexistent" not found as tag or commit in monalisa/octocat-skills`,
wantErr: `ref "nonexistent" not found as branch, tag, or commit in monalisa/octocat-skills`,
},
{
name: "no version uses latest release",
name: "branch wins over tag with same short name",
version: "release",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/release"),
httpmock.JSONResponse(map[string]interface{}{
"object": map[string]interface{}{"sha": "branch-sha"},
}))
// tag stub is not registered because branch succeeds first
},
wantRef: "refs/heads/release",
wantSHA: "branch-sha",
},
{
name: "fully qualified tag ref resolved directly",
version: "refs/tags/v1.0",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v1.0"),
httpmock.JSONResponse(map[string]interface{}{
"object": map[string]interface{}{"sha": "tag-sha", "type": "commit"},
}))
},
wantRef: "refs/tags/v1.0",
wantSHA: "tag-sha",
},
{
name: "fully qualified branch ref resolved directly",
version: "refs/heads/feature",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/feature"),
httpmock.JSONResponse(map[string]interface{}{
"object": map[string]interface{}{"sha": "feature-sha"},
}))
},
wantRef: "refs/heads/feature",
wantSHA: "feature-sha",
},
{
name: "fully qualified tag ref not found",
version: "refs/tags/nonexistent",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/nonexistent"),
httpmock.StatusStringResponse(404, "not found"))
},
wantErr: `tag "nonexistent" not found in monalisa/octocat-skills`,
},
{
name: "fully qualified branch ref not found",
version: "refs/heads/nonexistent",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/nonexistent"),
httpmock.StatusStringResponse(404, "not found"))
},
wantErr: `branch "nonexistent" not found in monalisa/octocat-skills`,
},
{
name: "no version uses latest release with fully qualified ref",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"),
@ -241,11 +365,11 @@ func TestResolveRef(t *testing.T) {
"object": map[string]interface{}{"sha": "release-sha", "type": "commit"},
}))
},
wantRef: "v3.0",
wantRef: "refs/tags/v3.0",
wantSHA: "release-sha",
},
{
name: "no version falls back to default branch when no releases",
name: "no version falls back to default branch with fully qualified ref",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"),
@ -259,12 +383,12 @@ func TestResolveRef(t *testing.T) {
"object": map[string]interface{}{"sha": "branch-sha"},
}))
},
wantRef: "main",
wantRef: "refs/heads/main",
wantSHA: "branch-sha",
},
{
name: "annotated tag dereference failure",
version: "v4.0",
version: "refs/tags/v4.0",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v4.0"),
@ -277,6 +401,24 @@ func TestResolveRef(t *testing.T) {
},
wantErr: "could not dereference annotated tag",
},
{
name: "no version with server error does not fall back to default branch",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"),
httpmock.StatusStringResponse(500, "internal server error"))
},
wantErr: "could not fetch latest release",
},
{
name: "no version with forbidden error does not fall back to default branch",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"),
httpmock.StatusStringResponse(403, "forbidden"))
},
wantErr: "could not fetch latest release",
},
{
name: "empty tag_name in latest release falls back to default branch",
stubs: func(reg *httpmock.Registry) {
@ -292,7 +434,7 @@ func TestResolveRef(t *testing.T) {
"object": map[string]interface{}{"sha": "fallback-sha"},
}))
},
wantRef: "main",
wantRef: "refs/heads/main",
wantSHA: "fallback-sha",
},
{
@ -310,7 +452,7 @@ func TestResolveRef(t *testing.T) {
"object": map[string]interface{}{"sha": "main-sha"},
}))
},
wantRef: "main",
wantRef: "refs/heads/main",
wantSHA: "main-sha",
},
}

View file

@ -89,13 +89,13 @@ func TestInjectGitHubMetadata(t *testing.T) {
host: "github.com",
owner: "monalisa",
repo: "octocat-skills",
ref: "v1.0.0",
ref: "refs/tags/v1.0.0",
treeSHA: "tree456",
pinnedRef: "",
skillPath: "skills/my-skill",
wantContains: []string{
"github-repo: https://github.com/monalisa/octocat-skills",
"github-ref: v1.0.0",
"github-ref: refs/tags/v1.0.0",
"github-tree-sha: tree456",
"github-path: skills/my-skill",
"# Body",
@ -117,7 +117,7 @@ func TestInjectGitHubMetadata(t *testing.T) {
host: "github.com",
owner: "monalisa",
repo: "octocat-skills",
ref: "v1.0.0",
ref: "refs/tags/v1.0.0",
treeSHA: "tree",
pinnedRef: "v1.0.0",
skillPath: "skills/my-skill",
@ -131,12 +131,13 @@ func TestInjectGitHubMetadata(t *testing.T) {
host: "github.com",
owner: "monalisa",
repo: "octocat-skills",
ref: "v1.0.0",
ref: "refs/heads/main",
treeSHA: "tree456",
pinnedRef: "",
skillPath: "skills/my-skill",
wantContains: []string{
"github-repo: https://github.com/monalisa/octocat-skills",
"github-ref: refs/heads/main",
"# Body only",
},
wantNotContain: []string{"github-owner", "github-sha"},

View file

@ -54,7 +54,6 @@ type installOptions struct {
ScopeChanged bool // true when --scope was explicitly set
Pin string // --pin flag
Dir string // --dir flag (overrides host+scope)
All bool // --all flag
Force bool // --force flag
// Resolved at runtime
@ -129,47 +128,44 @@ func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra.
Installed skills have GitHub tracking metadata injected into their
frontmatter (%[1]sgithub-repo%[1]s, %[1]sgithub-ref%[1]s,
%[1]sgithub-tree-sha%[1]s, %[1]sgithub-path%[1]s). This
metadata identifies the source repository and enables %[1]sgh skills update%[1]s
metadata identifies the source repository and enables %[1]sgh skill update%[1]s
to detect changes the tree SHA serves as an ETag for staleness checks.
The %[1]sgithub-repo%[1]s value is stored as a full repository URL.
When run interactively, the command prompts for any missing arguments.
When run non-interactively, %[1]srepository%[1]s is required, and either a
skill name or %[1]s--all%[1]s must be specified.
When run non-interactively, %[1]srepository%[1]s and a skill name are
required.
`, "`"),
Example: heredoc.Doc(`
# Interactive: choose repo, skill, and agent
$ gh skills install
$ gh skill install
# Choose a skill from the repo interactively
$ gh skills install github/awesome-copilot
$ gh skill install github/awesome-copilot
# Install a specific skill
$ gh skills install github/awesome-copilot git-commit
$ gh skill install github/awesome-copilot git-commit
# Install a specific version
$ gh skills install github/awesome-copilot git-commit@v1.2.0
# Install all skills from a repo
$ gh skills install github/awesome-copilot --all
$ gh skill install github/awesome-copilot git-commit@v1.2.0
# Install from a large namespaced repo by path (efficient, skips full discovery)
$ gh skills install github/awesome-copilot skills/monalisa/code-review
$ gh skill install github/awesome-copilot skills/monalisa/code-review
# Install from a local directory (auto-discovers skills)
$ gh skills install ./my-skills-repo
$ gh skill install ./my-skills-repo
# Install from current directory
$ gh skills install .
$ gh skill install .
# Install a single local skill directory
$ gh skills install ./skills/git-commit
$ gh skill install ./skills/git-commit
# Install for Claude Code at user scope
$ gh skills install github/awesome-copilot git-commit --agent claude-code --scope user
$ gh skill install github/awesome-copilot git-commit --agent claude-code --scope user
# Pin to a specific git ref
$ gh skills install github/awesome-copilot git-commit --pin v2.0.0
$ gh skill install github/awesome-copilot git-commit --pin v2.0.0
`),
Aliases: []string{"add"},
Args: cobra.MaximumNArgs(2),
@ -214,7 +210,6 @@ func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra.
cmdutil.StringEnumFlag(cmd, &opts.Scope, "scope", "", "project", []string{"project", "user"}, "Installation scope")
cmd.Flags().StringVar(&opts.Pin, "pin", "", "pin to a specific git tag or commit SHA")
cmd.Flags().StringVar(&opts.Dir, "dir", "", "install to a custom directory (overrides --agent and --scope)")
cmd.Flags().BoolVar(&opts.All, "all", false, "install all skills from the repository")
cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "overwrite existing skills without prompting")
return cmd
@ -327,11 +322,11 @@ func installRun(opts *installOptions) error {
for _, name := range result.Installed {
fmt.Fprintf(opts.IO.Out, "%s Installed %s (from %s@%s) in %s\n",
cs.SuccessIcon(), name, repoSource, resolved.Ref, friendlyDir(result.Dir))
cs.SuccessIcon(), name, repoSource, discovery.ShortRef(resolved.Ref), friendlyDir(result.Dir))
}
printFileTree(opts.IO.Out, cs, result.Dir, result.Installed)
printReviewHint(opts.IO.ErrOut, cs, repoSource, result.Installed)
printReviewHint(opts.IO.ErrOut, cs, repoSource, resolved.SHA, result.Installed)
}
if err != nil {
@ -444,7 +439,7 @@ func runLocalInstall(opts *installOptions) error {
}
printFileTree(opts.IO.Out, cs, result.Dir, result.Installed)
printReviewHint(opts.IO.ErrOut, cs, "", result.Installed)
printReviewHint(opts.IO.ErrOut, cs, "", "", result.Installed)
}
return nil
@ -515,7 +510,7 @@ func resolveVersion(opts *installOptions, client *api.Client, hostname string) (
if err != nil {
return nil, fmt.Errorf("could not resolve version: %w", err)
}
fmt.Fprintf(opts.IO.ErrOut, "Using ref %s (%s)\n", resolved.Ref, git.ShortSHA(resolved.SHA))
fmt.Fprintf(opts.IO.ErrOut, "Using ref %s (%s)\n", discovery.ShortRef(resolved.Ref), git.ShortSHA(resolved.SHA))
return resolved, nil
}
@ -575,19 +570,12 @@ func selectSkillsWithSelector(opts *installOptions, skills []discovery.Skill, ca
return collisionError(ss, sel.sourceHint)
}
if opts.All {
if err := checkCollisions(skills); err != nil {
return nil, err
}
return skills, nil
}
if opts.SkillName != "" {
return sel.matchByName(opts, skills)
}
if !canPrompt {
return nil, cmdutil.FlagErrorf("must specify a skill name or use --all when not running interactively")
return nil, cmdutil.FlagErrorf("must specify a skill name when not running interactively")
}
if sel.fetchDescriptions != nil {
@ -743,7 +731,7 @@ func collisionError(ss []discovery.Skill, sourceHint string) error {
cannot install skills with conflicting names they would overwrite each other:
%s
Install these skills individually using the full name:
gh skills install %s namespace/skill-name
gh skill install %s namespace/skill-name
`, discovery.FormatCollisions(collisions), sourceHint))
}
@ -1004,7 +992,9 @@ func printTreeDir(w io.Writer, cs *iostreams.ColorScheme, dir, indent string) {
}
// printReviewHint warns the user to review installed skills and suggests preview commands.
func printReviewHint(w io.Writer, cs *iostreams.ColorScheme, repo string, skillNames []string) {
// When sha is non-empty the suggested commands include @SHA so the user previews
// exactly the version that was installed.
func printReviewHint(w io.Writer, cs *iostreams.ColorScheme, repo, sha string, skillNames []string) {
if len(skillNames) == 0 {
return
}
@ -1016,7 +1006,11 @@ func printReviewHint(w io.Writer, cs *iostreams.ColorScheme, repo string, skillN
fmt.Fprintln(w, " Review installed content before use:")
fmt.Fprintln(w)
for _, name := range skillNames {
fmt.Fprintf(w, " gh skills preview %s %s\n", repo, name)
if sha != "" {
fmt.Fprintf(w, " gh skill preview %s %s@%s\n", repo, name, sha)
} else {
fmt.Fprintf(w, " gh skill preview %s %s\n", repo, name)
}
}
fmt.Fprintln(w)
}

View file

@ -53,11 +53,6 @@ func TestNewCmdInstall(t *testing.T) {
Force: true,
},
},
{
name: "all flag",
cli: "monalisa/skills-repo --all",
wantOpts: installOptions{SkillSource: "monalisa/skills-repo", All: true, Scope: "project"},
},
{
name: "dir flag",
cli: "monalisa/skills-repo git-commit --dir ./custom-skills",
@ -142,7 +137,6 @@ func TestNewCmdInstall(t *testing.T) {
assert.Equal(t, tt.wantOpts.Scope, gotOpts.Scope)
assert.Equal(t, tt.wantOpts.Pin, gotOpts.Pin)
assert.Equal(t, tt.wantOpts.Dir, gotOpts.Dir)
assert.Equal(t, tt.wantOpts.All, gotOpts.All)
assert.Equal(t, tt.wantOpts.Force, gotOpts.Force)
if tt.wantLocalPath {
assert.NotEmpty(t, gotOpts.localPath, "expected localPath to be set")
@ -164,7 +158,7 @@ func TestNewCmdInstall(t *testing.T) {
assert.NotEmpty(t, cmd.Example)
assert.Contains(t, cmd.Aliases, "add")
for _, flag := range []string{"agent", "scope", "pin", "all", "dir", "force"} {
for _, flag := range []string{"agent", "scope", "pin", "dir", "force"} {
assert.NotNil(t, cmd.Flags().Lookup(flag), "missing flag: --%s", flag)
}
})
@ -287,7 +281,7 @@ func TestInstallRun(t *testing.T) {
ScopeChanged: true,
}
},
wantErr: "must specify a skill name or use --all",
wantErr: "must specify a skill name when not running interactively",
},
{
name: "remote install writes files with tracking metadata",
@ -314,36 +308,6 @@ func TestInstallRun(t *testing.T) {
},
wantStdout: "Installed git-commit",
},
{
name: "remote install with --all installs multiple skills",
isTTY: true,
stubs: func(reg *httpmock.Registry) {
stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123")
treeJSON := fmt.Sprintf("%s, %s",
singleSkillTreeJSON("code-review", "tree0", "blob0"),
singleSkillTreeJSON("git-commit", "tree1", "blob1"))
stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON)
stubInstallFiles(reg, "monalisa", "skills-repo", "tree0", "blob0",
"---\nname: code-review\ndescription: Reviews\n---\n# B\n")
stubInstallFiles(reg, "monalisa", "skills-repo", "tree1", "blob1",
"---\nname: git-commit\ndescription: Commits\n---\n# A\n")
},
opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions {
t.Helper()
return &installOptions{
IO: ios,
HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil },
GitClient: &git.Client{RepoDir: t.TempDir()},
SkillSource: "monalisa/skills-repo",
All: true,
Agent: "github-copilot",
Scope: "project",
ScopeChanged: true,
Dir: t.TempDir(),
}
},
wantStdout: "Installed",
},
{
name: "remote install with --agent claude-code",
isTTY: true,
@ -597,6 +561,9 @@ func TestInstallRun(t *testing.T) {
name: "remote install with pin flag resolves version",
isTTY: true,
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/heads/v2.0.0"),
httpmock.StatusStringResponse(404, "not found"))
reg.Register(
httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/tags/v2.0.0"),
httpmock.StringResponse(`{"object": {"sha": "def456", "type": "commit"}}`),
@ -647,7 +614,7 @@ func TestInstallRun(t *testing.T) {
}
},
wantStdout: "Installed git-commit",
wantStderr: "prompt injections or malicious scripts",
wantStderr: "gh skill preview monalisa/skills-repo git-commit@abc123",
},
{
name: "remote install outputs file tree for TTY",
@ -678,6 +645,9 @@ func TestInstallRun(t *testing.T) {
name: "remote install with inline version parses name and version",
isTTY: true,
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/heads/v1.2.0"),
httpmock.StatusStringResponse(404, "not found"))
reg.Register(
httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/tags/v1.2.0"),
httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`),
@ -757,7 +727,7 @@ func TestInstallRun(t *testing.T) {
},
{
name: "remote install all with collisions errors",
isTTY: false,
isTTY: true,
stubs: func(reg *httpmock.Registry) {
stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123")
// Two skills with the same install name: skills/xlsx-pro and root xlsx-pro
@ -769,12 +739,17 @@ func TestInstallRun(t *testing.T) {
},
opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions {
t.Helper()
pm := &prompter.PrompterMock{
MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) {
return []string{allSkillsKey}, nil
},
}
return &installOptions{
IO: ios,
HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil },
Prompter: pm,
GitClient: &git.Client{RepoDir: t.TempDir()},
SkillSource: "monalisa/skills-repo",
All: true,
Agent: "github-copilot",
Scope: "project",
ScopeChanged: true,
@ -795,6 +770,15 @@ func TestInstallRun(t *testing.T) {
`{"path": "skills/bob/xlsx-pro", "type": "tree", "sha": "treeB"}, ` +
`{"path": "skills/bob/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobB"}`
stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON)
// Extra blob stubs consumed by FetchDescriptionsConcurrent during interactive selection.
contentA := base64.StdEncoding.EncodeToString([]byte("---\nname: xlsx-pro\ndescription: Alice\n---\n# A\n"))
contentB := base64.StdEncoding.EncodeToString([]byte("---\nname: xlsx-pro\ndescription: Bob\n---\n# B\n"))
reg.Register(
httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blobA"),
httpmock.StringResponse(fmt.Sprintf(`{"sha": "blobA", "content": %q, "encoding": "base64"}`, contentA)))
reg.Register(
httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blobB"),
httpmock.StringResponse(fmt.Sprintf(`{"sha": "blobB", "content": %q, "encoding": "base64"}`, contentB)))
stubInstallFiles(reg, "monalisa", "skills-repo", "treeA", "blobA",
"---\nname: xlsx-pro\ndescription: Alice\n---\n# A\n")
stubInstallFiles(reg, "monalisa", "skills-repo", "treeB", "blobB",
@ -802,12 +786,17 @@ func TestInstallRun(t *testing.T) {
},
opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions {
t.Helper()
pm := &prompter.PrompterMock{
MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) {
return []string{allSkillsKey}, nil
},
}
return &installOptions{
IO: ios,
HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil },
Prompter: pm,
GitClient: &git.Client{RepoDir: t.TempDir()},
SkillSource: "monalisa/skills-repo",
All: true,
Agent: "github-copilot",
Scope: "project",
ScopeChanged: true,
@ -1418,7 +1407,7 @@ func TestRunLocalInstall(t *testing.T) {
IO: ios,
SkillSource: sourceDir,
localPath: sourceDir,
All: true,
SkillName: "git-commit",
Force: true,
Agent: "github-copilot",
Scope: "project",
@ -1455,7 +1444,7 @@ func TestRunLocalInstall(t *testing.T) {
IO: ios,
SkillSource: sourceDir,
localPath: sourceDir,
All: true,
SkillName: "direct-skill",
Force: true,
Agent: "github-copilot",
Scope: "project",
@ -1468,7 +1457,7 @@ func TestRunLocalInstall(t *testing.T) {
},
{
name: "namespaced skills install to separate directories",
isTTY: false,
isTTY: true,
setup: func(t *testing.T, sourceDir, _ string) {
t.Helper()
for _, ns := range []string{"alice", "bob"} {
@ -1478,11 +1467,16 @@ func TestRunLocalInstall(t *testing.T) {
},
opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions {
t.Helper()
pm := &prompter.PrompterMock{
MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) {
return []string{allSkillsKey}, nil
},
}
return &installOptions{
IO: ios,
SkillSource: sourceDir,
localPath: sourceDir,
All: true,
Prompter: pm,
Force: true,
Agent: "github-copilot",
Scope: "project",
@ -1502,7 +1496,7 @@ func TestRunLocalInstall(t *testing.T) {
},
{
name: "local install with --force overwrites namespaced skill",
isTTY: false,
isTTY: true,
setup: func(t *testing.T, sourceDir, targetDir string) {
t.Helper()
for _, ns := range []string{"alice", "bob"} {
@ -1513,11 +1507,16 @@ func TestRunLocalInstall(t *testing.T) {
},
opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions {
t.Helper()
pm := &prompter.PrompterMock{
MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) {
return []string{allSkillsKey}, nil
},
}
return &installOptions{
IO: ios,
SkillSource: sourceDir,
localPath: sourceDir,
All: true,
Prompter: pm,
Force: true,
Agent: "github-copilot",
Scope: "project",
@ -1568,7 +1567,7 @@ func TestRunLocalInstall(t *testing.T) {
IO: ios,
SkillSource: sourceDir,
localPath: sourceDir,
All: true,
SkillName: "anything",
Agent: "github-copilot",
Scope: "project",
ScopeChanged: true,
@ -1597,7 +1596,7 @@ func TestRunLocalInstall(t *testing.T) {
IO: ios,
SkillSource: sourceDir,
localPath: sourceDir,
All: true,
SkillName: "git-commit",
Force: true,
Agent: "github-copilot",
Scope: "project",
@ -1627,7 +1626,7 @@ func TestRunLocalInstall(t *testing.T) {
IO: ios,
SkillSource: sourceDir,
localPath: sourceDir,
All: true,
SkillName: "git-commit",
Force: true,
Agent: "claude-code",
Scope: "project",
@ -1693,7 +1692,7 @@ func TestRunLocalInstall(t *testing.T) {
IO: ios,
SkillSource: sourceDir,
localPath: sourceDir,
All: true,
SkillName: "git-commit",
Force: true,
Agent: "github-copilot",
Scope: "project",
@ -1725,7 +1724,7 @@ func TestRunLocalInstall(t *testing.T) {
IO: ios,
SkillSource: "~/",
localPath: "~/",
All: true,
SkillName: "git-commit",
Force: true,
Agent: "github-copilot",
Scope: "project",
@ -1757,7 +1756,7 @@ func TestRunLocalInstall(t *testing.T) {
IO: ios,
SkillSource: "~",
localPath: "~",
All: true,
SkillName: "git-commit",
Force: true,
Agent: "github-copilot",
Scope: "project",
@ -1839,3 +1838,63 @@ func TestRunLocalInstall(t *testing.T) {
})
}
}
func Test_printReviewHint(t *testing.T) {
tests := []struct {
name string
repo string
sha string
skillNames []string
wantOutput string
}{
{
name: "remote install with SHA includes SHA in preview command",
repo: "owner/repo",
sha: "abc123def456",
skillNames: []string{"my-skill"},
wantOutput: "gh skill preview owner/repo my-skill@abc123def456",
},
{
name: "remote install without SHA omits SHA from preview command",
repo: "owner/repo",
sha: "",
skillNames: []string{"my-skill"},
wantOutput: "gh skill preview owner/repo my-skill\n",
},
{
name: "multiple skills with SHA",
repo: "owner/repo",
sha: "deadbeef",
skillNames: []string{"skill-a", "skill-b"},
wantOutput: "skill-a@deadbeef",
},
{
name: "local install shows generic message",
repo: "",
sha: "",
skillNames: []string{"my-skill"},
wantOutput: "Review the installed files before use",
},
{
name: "no skills produces no output",
repo: "owner/repo",
sha: "abc123",
skillNames: []string{},
wantOutput: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ios, _, _, _ := iostreams.Test()
cs := ios.ColorScheme()
var buf strings.Builder
printReviewHint(&buf, cs, tt.repo, tt.sha, tt.skillNames)
if tt.wantOutput == "" {
assert.Empty(t, buf.String())
} else {
assert.Contains(t, buf.String(), tt.wantOutput)
}
})
}
}

View file

@ -30,6 +30,7 @@ type previewOptions struct {
RepoArg string
SkillName string
Version string // resolved from @suffix on SkillName
repo ghrepo.Interface
}
@ -61,13 +62,23 @@ func NewCmdPreview(f *cmdutil.Factory, runF func(*previewOptions) error) *cobra.
When run with only a repository argument, lists available skills and
prompts for selection.
To preview a specific version of the skill, append @VERSION to the
skill name. The version is resolved as a git tag, branch, or commit
SHA.
`),
Example: heredoc.Doc(`
# Preview a specific skill
$ gh skills preview github/awesome-copilot code-review
$ gh skill preview github/awesome-copilot code-review
# Preview a skill at a specific version
$ gh skill preview github/awesome-copilot code-review@v1.2.0
# Preview a skill at a specific commit SHA
$ gh skill preview github/awesome-copilot code-review@abc123def456
# Browse and preview interactively
$ gh skills preview github/awesome-copilot
$ gh skill preview github/awesome-copilot
`),
Aliases: []string{"show"},
Args: cobra.RangeArgs(1, 2),
@ -77,6 +88,11 @@ func NewCmdPreview(f *cmdutil.Factory, runF func(*previewOptions) error) *cobra.
opts.SkillName = args[1]
}
if i := strings.LastIndex(opts.SkillName, "@"); i > 0 {
opts.Version = opts.SkillName[i+1:]
opts.SkillName = opts.SkillName[:i]
}
repo, err := ghrepo.FromFullName(opts.RepoArg)
if err != nil {
return err
@ -111,7 +127,7 @@ func previewRun(opts *previewOptions) error {
apiClient := api.NewClientFromHTTP(httpClient)
opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Resolving %s/%s", owner, repoName))
resolved, err := discovery.ResolveRef(apiClient, hostname, owner, repoName, "")
resolved, err := discovery.ResolveRef(apiClient, hostname, owner, repoName, opts.Version)
opts.IO.StopProgressIndicator()
if err != nil {
return fmt.Errorf("could not resolve version: %w", err)

View file

@ -25,6 +25,7 @@ func TestNewCmdPreview(t *testing.T) {
input string
wantRepo string
wantSkillName string
wantVersion string
wantErr bool
}{
{
@ -33,6 +34,20 @@ func TestNewCmdPreview(t *testing.T) {
wantRepo: "github/awesome-copilot",
wantSkillName: "my-skill",
},
{
name: "repo and skill with version",
input: "github/awesome-copilot my-skill@v1.2.0",
wantRepo: "github/awesome-copilot",
wantSkillName: "my-skill",
wantVersion: "v1.2.0",
},
{
name: "repo and skill with SHA",
input: "github/awesome-copilot my-skill@abc123def456",
wantRepo: "github/awesome-copilot",
wantSkillName: "my-skill",
wantVersion: "abc123def456",
},
{
name: "repo only",
input: "github/awesome-copilot",
@ -78,6 +93,7 @@ func TestNewCmdPreview(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, tt.wantRepo, gotOpts.RepoArg)
assert.Equal(t, tt.wantSkillName, gotOpts.SkillName)
assert.Equal(t, tt.wantVersion, gotOpts.Version)
})
}
}
@ -248,6 +264,55 @@ func TestPreviewRun(t *testing.T) {
},
wantErr: "must specify a skill name when not running interactively",
},
{
name: "preview with explicit version",
tty: true,
opts: &previewOptions{
repo: ghrepo.New("github", "awesome-copilot"),
SkillName: "my-skill",
Version: "abc123def456",
},
httpStubs: func(reg *httpmock.Registry) {
// ResolveRef with explicit version tries branch first, then tag, then commit
reg.Register(
httpmock.REST("GET", "repos/github/awesome-copilot/git/ref/heads/abc123def456"),
httpmock.StatusStringResponse(404, "not found"),
)
reg.Register(
httpmock.REST("GET", "repos/github/awesome-copilot/git/ref/tags/abc123def456"),
httpmock.StatusStringResponse(404, "not found"),
)
reg.Register(
httpmock.REST("GET", "repos/github/awesome-copilot/commits/abc123def456"),
httpmock.StringResponse(`{"sha": "abc123def456789012345678901234567890abcd"}`),
)
reg.Register(
httpmock.REST("GET", "repos/github/awesome-copilot/git/trees/abc123def456789012345678901234567890abcd"),
httpmock.StringResponse(`{
"sha": "abc123def456789012345678901234567890abcd",
"truncated": false,
"tree": [
{"path": "skills", "type": "tree", "sha": "tree1"},
{"path": "skills/my-skill", "type": "tree", "sha": "treeSHA"},
{"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blob123"}
]
}`),
)
reg.Register(
httpmock.REST("GET", "repos/github/awesome-copilot/git/trees/treeSHA"),
httpmock.StringResponse(`{
"tree": [
{"path": "SKILL.md", "type": "blob", "sha": "blob123", "size": 50}
]
}`),
)
reg.Register(
httpmock.REST("GET", "repos/github/awesome-copilot/git/blobs/blob123"),
httpmock.StringResponse(`{"sha": "blob123", "content": "`+encodedContent+`", "encoding": "base64"}`),
)
},
wantStdout: "My Skill",
},
}
for _, tt := range tests {

View file

@ -127,13 +127,13 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*publishOptions) error) *cobra.
`),
Example: heredoc.Doc(`
# Validate and publish interactively
$ gh skills publish
$ gh skill publish
# Publish with a specific tag (non-interactive)
$ gh skills publish --tag v1.0.0
$ gh skill publish --tag v1.0.0
# Validate only (no publish)
$ gh skills publish --dry-run
$ gh skill publish --dry-run
# Validate and strip install metadata
$ gh skills publish --fix
@ -621,8 +621,8 @@ func runPublishRelease(opts *publishOptions, client *api.Client, host, owner, re
}
fmt.Fprintf(opts.IO.Out, "%s Published %s\n", cs.SuccessIcon(), tag)
fmt.Fprintf(opts.IO.Out, "%s Install with: gh skills install %s/%s\n", cs.SuccessIcon(), owner, repo)
fmt.Fprintf(opts.IO.Out, "%s Pin with: gh skills install %s/%s <skill> --pin %s\n", cs.SuccessIcon(), owner, repo, tag)
fmt.Fprintf(opts.IO.Out, "%s Install with: gh skill install %s/%s\n", cs.SuccessIcon(), owner, repo)
fmt.Fprintf(opts.IO.Out, "%s Pin with: gh skill install %s/%s <skill> --pin %s\n", cs.SuccessIcon(), owner, repo, tag)
return nil
}

View file

@ -89,16 +89,16 @@ func NewCmdSearch(f *cmdutil.Factory, runF func(*searchOptions) error) *cobra.Co
`),
Example: heredoc.Doc(`
# Search for skills related to terraform
$ gh skills search terraform
$ gh skill search terraform
# Search for skills from a specific owner
$ gh skills search terraform --owner hashicorp
$ gh skill search terraform --owner hashicorp
# View the second page of results
$ gh skills search terraform --page 2
$ gh skill search terraform --page 2
# Limit results to 5
$ gh skills search terraform --limit 5
$ gh skill search terraform --limit 5
`),
Args: cmdutil.MinimumArgs(1, "cannot search: query argument required"),
RunE: func(c *cobra.Command, args []string) error {

View file

@ -10,12 +10,13 @@ import (
"github.com/spf13/cobra"
)
// NewCmdSkills returns the top-level "skills" command.
// NewCmdSkills returns the top-level "skill" command.
func NewCmdSkills(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "skills <command>",
Use: "skill <command>",
Short: "Install and manage agent skills",
Long: "Install and manage agent skills from GitHub repositories.",
Aliases: []string{"skills"},
GroupID: "core",
}

View file

@ -107,22 +107,22 @@ func NewCmdUpdate(f *cmdutil.Factory, runF func(*updateOptions) error) *cobra.Co
`),
Example: heredoc.Doc(`
# Check and update all skills interactively
$ gh skills update
$ gh skill update
# Update specific skills
$ gh skills update mcp-cli git-commit
$ gh skill update mcp-cli git-commit
# Update all without prompting
$ gh skills update --all
$ gh skill update --all
# Re-download all skills (restore locally modified files)
$ gh skills update --force --all
$ gh skill update --force --all
# Check for updates without applying (read-only)
$ gh skills update --dry-run
$ gh skill update --dry-run
# Unpin skills and update them to latest
$ gh skills update --unpin
$ gh skill update --unpin
`),
RunE: func(cmd *cobra.Command, args []string) error {
opts.Skills = args
@ -344,12 +344,12 @@ func updateRun(opts *updateOptions) error {
if u.local.treeSHA == u.newSHA {
fmt.Fprintf(opts.IO.Out, " %s %s (%s/%s) %s (reinstall) [%s]\n",
cs.Cyan("•"), u.local.name, u.local.owner, u.local.repo,
git.ShortSHA(u.newSHA), u.resolved.Ref)
git.ShortSHA(u.newSHA), discovery.ShortRef(u.resolved.Ref))
} else {
fmt.Fprintf(opts.IO.Out, " %s %s (%s/%s) %s → %s [%s]\n",
cs.Cyan("•"), u.local.name, u.local.owner, u.local.repo,
cs.Muted(git.ShortSHA(u.local.treeSHA)), git.ShortSHA(u.newSHA),
u.resolved.Ref)
discovery.ShortRef(u.resolved.Ref))
}
}
fmt.Fprintln(opts.IO.ErrOut)