Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions pkg/cmd/skills/install/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ type InstallOptions struct {
ScopeChanged bool // true when --scope was explicitly set
Pin string
Dir string // overrides --agent and --scope
All bool
Force bool
FromLocal bool // treat SkillSource as a local directory path
AllowHiddenDirs bool // include skills in dot-prefixed directories
Expand Down Expand Up @@ -135,9 +136,9 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru
frontmatter. This metadata identifies the source repository and
enables %[1]sgh skill update%[1]s to detect changes.

When run interactively, the command prompts for any missing arguments.
When run non-interactively, %[1]srepository%[1]s and a skill name are
required.
Use %[1]s--all%[1]s to install every discovered skill from the repository
without prompting for skill selection. When run non-interactively, %[1]srepository%[1]s and either
a skill name or %[1]s--all%[1]s are required.
`, "`", registry.AgentHelpList()),
Example: heredoc.Doc(`
# Interactive: choose repo, skill, and agent
Expand All @@ -149,6 +150,9 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru
# Install a specific skill
$ gh skill install github/awesome-copilot git-commit

# Install all skills from a repository
$ gh skill install github/awesome-copilot --all

# Install a specific version
$ gh skill install github/awesome-copilot git-commit@v1.2.0

Expand Down Expand Up @@ -181,6 +185,10 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru
}
opts.ScopeChanged = cmd.Flags().Changed("scope")

if opts.All && opts.SkillName != "" {
return cmdutil.FlagErrorf("cannot use --all with a skill argument")
}

// Resolve the source type early so installRun can branch directly.
if opts.FromLocal {
if opts.SkillSource == "" {
Expand Down Expand Up @@ -215,6 +223,7 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru
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 without prompting for skill selection")
cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Overwrite existing skills without prompting")
cmd.Flags().BoolVar(&opts.FromLocal, "from-local", false, "Treat the argument as a local directory path instead of a repository")
cmd.Flags().BoolVar(&opts.AllowHiddenDirs, "allow-hidden-dirs", false, "Include skills in hidden directories (e.g. .claude/skills/, .agents/skills/)")
Expand Down Expand Up @@ -682,6 +691,13 @@ func selectSkillsWithSelector(opts *InstallOptions, skills []discovery.Skill, ca
return nil
}

if opts.All {
if err := checkCollisions(skills); err != nil {
return nil, err
}
return skills, nil
}

if opts.SkillName != "" {
return sel.matchByName(opts, skills)
}
Expand Down
60 changes: 59 additions & 1 deletion pkg/cmd/skills/install/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ func TestNewCmdInstall(t *testing.T) {
cli: "monalisa/skills-repo git-commit",
wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Scope: "project"},
},
{
name: "repo and all flag",
cli: "monalisa/skills-repo --all",
wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", All: true, Scope: "project"},
},
{
name: "all flags",
cli: "monalisa/skills-repo git-commit --agent github-copilot --scope user --pin v1.0.0 --force",
Expand Down Expand Up @@ -77,6 +82,11 @@ func TestNewCmdInstall(t *testing.T) {
cli: "monalisa/skills-repo git-commit@v1.0.0 --pin v2.0.0",
wantErr: true,
},
{
name: "all conflicts with skill name",
cli: "monalisa/skills-repo git-commit --all",
wantErr: true,
},
{
name: "alias add works",
cli: "monalisa/skills-repo git-commit",
Expand Down Expand Up @@ -171,6 +181,7 @@ 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)
assert.Equal(t, tt.wantOpts.FromLocal, gotOpts.FromLocal)
assert.Equal(t, tt.wantOpts.AllowHiddenDirs, gotOpts.AllowHiddenDirs)
Expand All @@ -194,7 +205,7 @@ func TestNewCmdInstall(t *testing.T) {
assert.NotEmpty(t, cmd.Example)
assert.Contains(t, cmd.Aliases, "add")

for _, flag := range []string{"agent", "scope", "pin", "dir", "force"} {
for _, flag := range []string{"agent", "scope", "pin", "dir", "all", "force"} {
assert.NotNil(t, cmd.Flags().Lookup(flag), "missing flag: --%s", flag)
}
})
Expand Down Expand Up @@ -265,6 +276,14 @@ var gitCommitContent = heredoc.Doc(`
# Git Commit
`)

var codeReviewContent = heredoc.Doc(`
---
name: code-review
description: Reviews code
---
# Code Review
`)

// singleSkillTreeJSON returns tree entries for a single skill with the given name.
func singleSkillTreeJSON(name, treeSHA, blobSHA string) string {
return fmt.Sprintf(
Expand Down Expand Up @@ -1529,6 +1548,45 @@ func TestInstallRun(t *testing.T) {
}
}

func TestInstallRun_AllInstallsRemoteSkills(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
t.Setenv("USERPROFILE", homeDir)

reg := &httpmock.Registry{}
defer reg.Verify(t)

stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123")
stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123",
singleSkillTreeJSON("code-review", "tree-cr", "blob-cr")+", "+
singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc"))
stubInstallFiles(reg, "monalisa", "skills-repo", "tree-cr", "blob-cr", codeReviewContent)
stubInstallFiles(reg, "monalisa", "skills-repo", "tree-gc", "blob-gc", gitCommitContent)

ios, _, stdout, stderr := iostreams.Test()
targetDir := t.TempDir()

err := installRun(&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,
Force: true,
Agent: "github-copilot",
Scope: "project",
ScopeChanged: true,
Dir: targetDir,
Telemetry: &telemetry.NoOpService{},
})
require.NoError(t, err)
assert.Contains(t, stdout.String(), "Installed code-review")
assert.Contains(t, stdout.String(), "Installed git-commit")
assert.NotContains(t, stderr.String(), "must specify a skill name")
require.FileExists(t, filepath.Join(targetDir, "code-review", "SKILL.md"))
require.FileExists(t, filepath.Join(targetDir, "git-commit", "SKILL.md"))
}

func TestInstallProgress(t *testing.T) {
ios, _, _, _ := iostreams.Test()

Expand Down
Loading