diff --git a/internal/skills/discovery/discovery.go b/internal/skills/discovery/discovery.go index 05a531bc9..4e54fd5e3 100644 --- a/internal/skills/discovery/discovery.go +++ b/internal/skills/discovery/discovery.go @@ -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/", + " Use path-based install instead: gh skill install %s/%s skills/", owner, repo, owner, repo, ) } diff --git a/internal/skills/discovery/discovery_test.go b/internal/skills/discovery/discovery_test.go index 974052530..3bc719ae8 100644 --- a/internal/skills/discovery/discovery_test.go +++ b/internal/skills/discovery/discovery_test.go @@ -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", }, } diff --git a/internal/skills/frontmatter/frontmatter_test.go b/internal/skills/frontmatter/frontmatter_test.go index 229eadd18..d88811ea2 100644 --- a/internal/skills/frontmatter/frontmatter_test.go +++ b/internal/skills/frontmatter/frontmatter_test.go @@ -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"}, diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index 0cc5c6131..fc65e2f0c 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -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) } diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index 060b146a4..b15f5a9b2 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -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) + } + }) + } +} diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index 55ba125e3..ee33b04c1 100644 --- a/pkg/cmd/skills/preview/preview.go +++ b/pkg/cmd/skills/preview/preview.go @@ -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) diff --git a/pkg/cmd/skills/preview/preview_test.go b/pkg/cmd/skills/preview/preview_test.go index cd623fa06..debdfbff2 100644 --- a/pkg/cmd/skills/preview/preview_test.go +++ b/pkg/cmd/skills/preview/preview_test.go @@ -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 { diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index 96683bece..22d87bb73 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -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 --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 --pin %s\n", cs.SuccessIcon(), owner, repo, tag) return nil } diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go index 03874c30c..48ea9f358 100644 --- a/pkg/cmd/skills/search/search.go +++ b/pkg/cmd/skills/search/search.go @@ -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 { diff --git a/pkg/cmd/skills/skills.go b/pkg/cmd/skills/skills.go index 8a1367314..8f9c45faf 100644 --- a/pkg/cmd/skills/skills.go +++ b/pkg/cmd/skills/skills.go @@ -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 ", + Use: "skill ", Short: "Install and manage agent skills", Long: "Install and manage agent skills from GitHub repositories.", + Aliases: []string{"skills"}, GroupID: "core", } diff --git a/pkg/cmd/skills/update/update.go b/pkg/cmd/skills/update/update.go index 0a9d1b1fa..11db14a34 100644 --- a/pkg/cmd/skills/update/update.go +++ b/pkg/cmd/skills/update/update.go @@ -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)