Skip to content

Commit 70bb306

Browse files
feat(skills): list available skills when install runs non-interactively (cli#13548)
Co-authored-by: sammorrowdrums <sammorrowdrums@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 01bcd47 commit 70bb306

2 files changed

Lines changed: 105 additions & 5 deletions

File tree

pkg/cmd/skills/install/install.go

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"github.com/cli/cli/v2/internal/skills/installer"
2525
"github.com/cli/cli/v2/internal/skills/registry"
2626
"github.com/cli/cli/v2/internal/skills/source"
27+
"github.com/cli/cli/v2/internal/tableprinter"
2728
"github.com/cli/cli/v2/internal/text"
2829
"github.com/cli/cli/v2/pkg/cmdutil"
2930
"github.com/cli/cli/v2/pkg/iostreams"
@@ -138,9 +139,14 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru
138139
frontmatter. This metadata identifies the source repository and
139140
enables %[1]sgh skill update%[1]s to detect changes.
140141
142+
When run interactively, the command prompts for any missing arguments.
143+
141144
Use %[1]s--all%[1]s to install every discovered skill from the repository
142-
without prompting for skill selection. When run non-interactively, %[1]srepository%[1]s and either
143-
a skill name or %[1]s--all%[1]s are required.
145+
without prompting for skill selection. When run non-interactively,
146+
%[1]srepository%[1]s is required; without a skill name or %[1]s--all%[1]s the
147+
matching skills are listed (as tab-separated values when piped) so you can
148+
browse or filter them with tools like %[1]sgrep%[1]s before re-running with
149+
a specific skill.
144150
`, "`", registry.AgentHelpList()),
145151
Example: heredoc.Doc(`
146152
# Interactive: choose repo, skill, and agent
@@ -149,6 +155,9 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru
149155
# Choose a skill from the repo interactively
150156
$ gh skill install github/awesome-copilot
151157
158+
# List available skills non-interactively (e.g. to pipe into grep)
159+
$ gh skill install github/awesome-copilot | grep review
160+
152161
# Install a specific skill
153162
$ gh skill install github/awesome-copilot git-commit
154163
@@ -314,6 +323,9 @@ func installRun(opts *InstallOptions) error {
314323
},
315324
})
316325
if err != nil {
326+
if errors.Is(err, errSkillsListed) {
327+
return nil
328+
}
317329
return err
318330
}
319331
}
@@ -507,6 +519,9 @@ func runLocalInstall(opts *InstallOptions) error {
507519
sourceHint: absSource,
508520
})
509521
if err != nil {
522+
if errors.Is(err, errSkillsListed) {
523+
return nil
524+
}
510525
return err
511526
}
512527

@@ -669,6 +684,12 @@ type installPlan struct {
669684
skills []discovery.Skill
670685
}
671686

687+
// errSkillsListed is a sentinel returned by selectSkillsWithSelector when
688+
// the command runs non-interactively without a skill name. In that case the
689+
// selector prints the available skills to stdout (so they can be piped into
690+
// grep or similar) and the caller exits without installing.
691+
var errSkillsListed = errors.New("skills listed")
692+
672693
func selectSkillsWithSelector(opts *InstallOptions, skills []discovery.Skill, canPrompt bool, sel skillSelector) ([]discovery.Skill, error) {
673694
checkCollisions := func(ss []discovery.Skill) error {
674695
if err := collisionError(ss); err != nil {
@@ -690,7 +711,10 @@ func selectSkillsWithSelector(opts *InstallOptions, skills []discovery.Skill, ca
690711
}
691712

692713
if !canPrompt {
693-
return nil, cmdutil.FlagErrorf("must specify a skill name when not running interactively")
714+
if err := listAvailableSkills(opts, skills, sel); err != nil {
715+
return nil, err
716+
}
717+
return nil, errSkillsListed
694718
}
695719

696720
if sel.fetchDescriptions != nil {
@@ -734,6 +758,43 @@ func selectSkillsWithSelector(opts *InstallOptions, skills []discovery.Skill, ca
734758
return result, checkCollisions(result)
735759
}
736760

761+
// listAvailableSkills prints discovered skills as a table for non-interactive
762+
// callers, mirroring the information shown in the interactive picker so the
763+
// output can be browsed or piped into tools like grep.
764+
func listAvailableSkills(opts *InstallOptions, skills []discovery.Skill, sel skillSelector) error {
765+
if len(skills) == 0 {
766+
return fmt.Errorf("no skills found in %s", sel.sourceHint)
767+
}
768+
769+
if sel.fetchDescriptions != nil {
770+
sel.fetchDescriptions()
771+
}
772+
773+
if opts.IO.IsStdoutTTY() {
774+
fmt.Fprintf(opts.IO.ErrOut, "Showing %s from %s. Re-run with a skill name to install.\n\n",
775+
text.Pluralize(len(skills), "skill"), sel.sourceHint)
776+
}
777+
778+
tw := opts.IO.TerminalWidth()
779+
descWidth := tw - 40
780+
if descWidth < 20 {
781+
descWidth = 20
782+
}
783+
isTTY := opts.IO.IsStdoutTTY()
784+
785+
table := tableprinter.New(opts.IO, tableprinter.WithHeader("SKILL", "DESCRIPTION"))
786+
for _, s := range skills {
787+
table.AddField(s.DisplayName())
788+
desc := s.Description
789+
if isTTY {
790+
desc = text.Truncate(descWidth, desc)
791+
}
792+
table.AddField(desc)
793+
table.EndRow()
794+
}
795+
return table.Render()
796+
}
797+
737798
func matchSkillByName(opts *InstallOptions, skills []discovery.Skill) ([]discovery.Skill, error) {
738799
for _, s := range skills {
739800
if s.DisplayName() == opts.SkillName {

pkg/cmd/skills/install/install_test.go

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -326,12 +326,17 @@ func TestInstallRun(t *testing.T) {
326326
wantErr: "must specify a repository to install from",
327327
},
328328
{
329-
name: "non-interactive without skill name errors",
329+
name: "non-interactive without skill name lists available skills",
330330
isTTY: false,
331331
stubs: func(reg *httpmock.Registry) {
332332
stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123")
333333
stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123",
334334
singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA"))
335+
encoded := base64.StdEncoding.EncodeToString([]byte(gitCommitContent))
336+
reg.Register(
337+
httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blobSHA"),
338+
httpmock.StringResponse(fmt.Sprintf(`{"sha": "blobSHA", "content": %q, "encoding": "base64"}`, encoded)),
339+
)
335340
},
336341
opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions {
337342
t.Helper()
@@ -345,7 +350,7 @@ func TestInstallRun(t *testing.T) {
345350
ScopeChanged: true,
346351
}
347352
},
348-
wantErr: "must specify a skill name when not running interactively",
353+
wantStdout: "git-commit\tWrites commits\n",
349354
},
350355
{
351356
name: "remote install writes files with tracking metadata",
@@ -1998,6 +2003,40 @@ func TestRunLocalInstall(t *testing.T) {
19982003
},
19992004
wantStdout: "Installed git-commit",
20002005
},
2006+
{
2007+
name: "local install without skill name lists available skills",
2008+
isTTY: false,
2009+
setup: func(t *testing.T, sourceDir, _ string) {
2010+
t.Helper()
2011+
writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(`
2012+
---
2013+
name: git-commit
2014+
description: A local skill
2015+
---
2016+
# Git Commit
2017+
`))
2018+
writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "code-review"), heredoc.Doc(`
2019+
---
2020+
name: code-review
2021+
description: Reviews code
2022+
---
2023+
# Code Review
2024+
`))
2025+
},
2026+
opts: func(ios *iostreams.IOStreams, sourceDir, _ string) *InstallOptions {
2027+
t.Helper()
2028+
return &InstallOptions{
2029+
IO: ios,
2030+
SkillSource: sourceDir,
2031+
localPath: sourceDir,
2032+
Agent: "github-copilot",
2033+
Scope: "project",
2034+
ScopeChanged: true,
2035+
GitClient: &git.Client{RepoDir: t.TempDir()},
2036+
}
2037+
},
2038+
wantStdout: "code-review\tReviews code\ngit-commit\tA local skill\n",
2039+
},
20012040
{
20022041
name: "local install outputs file tree for TTY",
20032042
isTTY: true,

0 commit comments

Comments
 (0)