add skill list command

This commit is contained in:
tommaso-moro 2026-05-11 10:54:57 +01:00
parent 9b505c3fb8
commit d9fab039ab
3 changed files with 833 additions and 0 deletions

480
pkg/cmd/skills/list/list.go Normal file
View file

@ -0,0 +1,480 @@
package list
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/gh/ghtelemetry"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/skills/discovery"
"github.com/cli/cli/v2/internal/skills/frontmatter"
"github.com/cli/cli/v2/internal/skills/installer"
"github.com/cli/cli/v2/internal/skills/registry"
"github.com/cli/cli/v2/internal/skills/source"
"github.com/cli/cli/v2/internal/tableprinter"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/spf13/cobra"
)
var skillListFields = []string{
"skillName",
"hosts",
"scope",
"sourceURL",
"version",
"pinned",
"path",
}
// ListOptions holds dependencies and user-provided flags for the list command.
type ListOptions struct {
IO *iostreams.IOStreams
Telemetry ghtelemetry.EventRecorder
GitClient *git.Client
Exporter cmdutil.Exporter
Agent string
Scope string
ScopeChanged bool
Dir string
}
type agentInfo struct {
id string
}
type scanTarget struct {
dir string
hosts []agentInfo
scope string
}
type listedSkill struct {
skillName string
hostIDs []string
scope string
source string
sourceURL string
version string
pinned bool
path string
}
// ExportData implements cmdutil.exportable for --json output.
func (s listedSkill) ExportData(fields []string) map[string]interface{} {
data := map[string]interface{}{}
for _, f := range fields {
switch f {
case "skillName":
data[f] = s.skillName
case "hosts":
data[f] = s.hostIDs
case "scope":
data[f] = s.scope
case "sourceURL":
data[f] = s.sourceURL
case "version":
data[f] = s.version
case "pinned":
data[f] = s.pinned
case "path":
data[f] = s.path
}
}
return data
}
// NewCmdList creates the "skills list" command.
func NewCmdList(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF func(*ListOptions) error) *cobra.Command {
opts := &ListOptions{
IO: f.IOStreams,
Telemetry: telemetry,
GitClient: f.GitClient,
}
cmd := &cobra.Command{
Use: "list [flags]",
Short: "List installed skills (preview)",
Aliases: []string{"ls"},
Long: heredoc.Docf(`
List installed agent skills across known agent host directories.
By default, scans all supported agent hosts in both project and user scope.
Use %[1]s--agent%[1]s to scan one host, %[1]s--scope%[1]s to scan only project or user
scope, or %[1]s--dir%[1]s to scan a custom skills directory.
Project-scope skills are discovered relative to the current git repository
root. User-scope skills are discovered relative to your home directory.
`, "`"),
Example: heredoc.Doc(`
# List all installed skills
$ gh skill list
# List skills installed for Claude Code
$ gh skill list --agent claude-code
# List user-scope skills
$ gh skill list --scope user
# List skills as JSON
$ gh skill list --json skillName,sourceURL,scope,version,pinned,path
`),
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
opts.ScopeChanged = cmd.Flags().Changed("scope")
if opts.Dir != "" && opts.Agent != "" {
return cmdutil.FlagErrorf("--dir and --agent cannot be used together")
}
if opts.Dir != "" && opts.ScopeChanged {
return cmdutil.FlagErrorf("--dir and --scope cannot be used together")
}
if runF != nil {
return runF(opts)
}
return listRun(opts)
},
}
cmdutil.StringEnumFlag(cmd, &opts.Agent, "agent", "", "", registry.AgentIDs(), "Filter by target agent")
cmdutil.StringEnumFlag(cmd, &opts.Scope, "scope", "", "", []string{string(registry.ScopeProject), string(registry.ScopeUser)}, "Filter by installation scope")
cmd.Flags().StringVar(&opts.Dir, "dir", "", "Scan a custom directory for installed skills")
cmdutil.AddJSONFlags(cmd, &opts.Exporter, skillListFields)
return cmd
}
func listRun(opts *ListOptions) error {
skills, err := listInstalledSkills(opts)
if err != nil {
return err
}
sortListedSkills(skills)
recordListTelemetry(opts, len(skills))
if opts.Exporter != nil {
return opts.Exporter.Write(opts.IO, skills)
}
if len(skills) == 0 {
return cmdutil.NewNoResultsError("no installed skills found")
}
return renderTable(opts.IO, skills)
}
func listInstalledSkills(opts *ListOptions) ([]listedSkill, error) {
targets, err := buildScanTargets(opts)
if err != nil {
return nil, err
}
var all []listedSkill
for _, target := range targets {
skills, scanErr := scanInstalledSkills(target.dir, target.hosts, target.scope)
if scanErr != nil {
if opts.Dir != "" {
return nil, fmt.Errorf("could not scan directory: %w", scanErr)
}
continue
}
all = append(all, skills...)
}
return all, nil
}
func buildScanTargets(opts *ListOptions) ([]scanTarget, error) {
if opts.Dir != "" {
dir, err := filepath.Abs(opts.Dir)
if err != nil {
return nil, fmt.Errorf("could not resolve path: %w", err)
}
return []scanTarget{{dir: dir, scope: "custom"}}, nil
}
gitRoot := installer.ResolveGitRoot(opts.GitClient)
homeDir := installer.ResolveHomeDir()
hosts, err := selectedHosts(opts.Agent)
if err != nil {
return nil, err
}
scopes := selectedScopes(opts.Scope)
byDir := map[string]int{}
var targets []scanTarget
for _, host := range hosts {
for _, scope := range scopes {
dir, installErr := host.InstallDir(scope, gitRoot, homeDir)
if installErr != nil {
continue
}
if idx, ok := byDir[dir]; ok {
targets[idx].hosts = appendHost(targets[idx].hosts, host)
continue
}
byDir[dir] = len(targets)
targets = append(targets, scanTarget{
dir: dir,
hosts: []agentInfo{{id: host.ID}},
scope: string(scope),
})
}
}
return targets, nil
}
func selectedHosts(agentID string) ([]*registry.AgentHost, error) {
if agentID != "" {
host, err := registry.FindByID(agentID)
if err != nil {
return nil, err
}
return []*registry.AgentHost{host}, nil
}
hosts := make([]*registry.AgentHost, len(registry.Agents))
for i := range registry.Agents {
hosts[i] = &registry.Agents[i]
}
return hosts, nil
}
func selectedScopes(scope string) []registry.Scope {
if scope != "" {
return []registry.Scope{registry.Scope(scope)}
}
return []registry.Scope{registry.ScopeProject, registry.ScopeUser}
}
func appendHost(hosts []agentInfo, host *registry.AgentHost) []agentInfo {
for _, existing := range hosts {
if existing.id == host.ID {
return hosts
}
}
return append(hosts, agentInfo{id: host.ID})
}
func scanInstalledSkills(skillsDir string, hosts []agentInfo, scope string) ([]listedSkill, error) {
entries, err := os.ReadDir(skillsDir)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("could not read skills directory: %w", err)
}
var skills []listedSkill
for _, e := range entries {
if !e.IsDir() {
continue
}
// Flat layout: {dir}/{name}/SKILL.md.
skillDir := filepath.Join(skillsDir, e.Name())
skillFile := filepath.Join(skillDir, "SKILL.md")
if data, readErr := os.ReadFile(skillFile); readErr == nil {
skills = append(skills, parseInstalledSkill(data, e.Name(), skillDir, hosts, scope))
continue
}
// Namespaced layout: {dir}/{namespace}/{name}/SKILL.md.
subEntries, subErr := os.ReadDir(skillDir)
if subErr != nil {
continue
}
for _, sub := range subEntries {
if !sub.IsDir() {
continue
}
subSkillDir := filepath.Join(skillDir, sub.Name())
subSkillFile := filepath.Join(subSkillDir, "SKILL.md")
if data, readErr := os.ReadFile(subSkillFile); readErr == nil {
installName := e.Name() + "/" + sub.Name()
skills = append(skills, parseInstalledSkill(data, installName, subSkillDir, hosts, scope))
}
}
}
return skills, nil
}
func parseInstalledSkill(data []byte, name, dir string, hosts []agentInfo, scope string) listedSkill {
s := listedSkill{
skillName: name,
hostIDs: hostIDs(hosts),
scope: scope,
path: dir,
}
result, err := frontmatter.Parse(string(data))
if err != nil {
return s
}
meta := result.Metadata.Meta
if meta == nil {
return s
}
if sourcePath, _ := meta["github-path"].(string); sourcePath != "" {
if skillName := skillNameFromSourcePath(sourcePath); skillName != "" {
s.skillName = skillName
}
}
if repoURL, _ := meta["github-repo"].(string); repoURL != "" {
s.sourceURL = repoURL
s.source = repoURL
if repo, parseErr := source.ParseRepoURL(repoURL); parseErr == nil {
s.source = ghrepo.FullName(repo)
s.sourceURL = source.BuildRepoURL(repo.RepoHost(), repo.RepoOwner(), repo.RepoName())
}
} else if localPath, _ := meta["local-path"].(string); localPath != "" {
s.sourceURL = localPath
s.source = localPath
}
if ref, _ := meta["github-ref"].(string); ref != "" {
s.version = discovery.ShortRef(ref)
}
if pinnedRef, _ := meta["github-pinned"].(string); pinnedRef != "" {
s.pinned = true
if s.version == "" {
s.version = pinnedRef
}
}
return s
}
func skillNameFromSourcePath(sourcePath string) string {
sourcePath = strings.TrimSuffix(sourcePath, "/SKILL.md")
sourcePath = strings.Trim(sourcePath, "/")
if sourcePath == "" {
return ""
}
parts := strings.Split(sourcePath, "/")
for i := len(parts) - 1; i >= 0; i-- {
if parts[i] != "skills" {
continue
}
if i >= 2 && parts[i-2] == "plugins" && i+1 < len(parts) {
return parts[i-1] + "/" + parts[len(parts)-1]
}
afterSkills := len(parts) - i - 1
switch afterSkills {
case 0:
return ""
case 1:
return parts[i+1]
default:
return parts[i+1] + "/" + parts[len(parts)-1]
}
}
return parts[len(parts)-1]
}
func hostIDs(hosts []agentInfo) []string {
ids := make([]string, len(hosts))
for i, host := range hosts {
ids[i] = host.id
}
return ids
}
func sortListedSkills(skills []listedSkill) {
sort.Slice(skills, func(i, j int) bool {
if skills[i].skillName != skills[j].skillName {
return skills[i].skillName < skills[j].skillName
}
if skills[i].scope != skills[j].scope {
return skills[i].scope < skills[j].scope
}
if formatHosts(skills[i].hostIDs) != formatHosts(skills[j].hostIDs) {
return formatHosts(skills[i].hostIDs) < formatHosts(skills[j].hostIDs)
}
return skills[i].path < skills[j].path
})
}
func renderTable(io *iostreams.IOStreams, skills []listedSkill) error {
table := tableprinter.New(io, tableprinter.WithHeader("Name", "Agent", "Scope", "Source"))
for _, skill := range skills {
table.AddField(skill.skillName)
table.AddField(formatHosts(skill.hostIDs))
table.AddField(displayOrDash(skill.scope))
table.AddField(displayOrDash(skill.source))
table.EndRow()
}
return table.Render()
}
func displayOrDash(value string) string {
if value == "" {
return "-"
}
return value
}
func formatHosts(hosts []string) string {
if len(hosts) == 0 {
return "-"
}
return strings.Join(hosts, ",")
}
func recordListTelemetry(opts *ListOptions, skillCount int) {
if opts.Telemetry == nil {
return
}
agentHosts := opts.Agent
if agentHosts == "" {
agentHosts = "all"
}
scope := opts.Scope
if scope == "" {
scope = "all"
}
customDir := "false"
if opts.Dir != "" {
customDir = "true"
scope = "custom"
}
format := "table"
if opts.Exporter != nil {
format = "json"
}
opts.Telemetry.Record(ghtelemetry.Event{
Type: "skill_list",
Dimensions: ghtelemetry.Dimensions{
"agent_hosts": agentHosts,
"custom_dir": customDir,
"format": format,
"scope": scope,
},
Measures: ghtelemetry.Measures{
"skill_count": int64(skillCount),
},
})
}

View file

@ -0,0 +1,348 @@
package list
import (
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/telemetry"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewCmdList(t *testing.T) {
tests := []struct {
name string
cli string
wantOpts ListOptions
wantJSON bool
wantErr string
}{
{
name: "no flags",
cli: "",
wantOpts: ListOptions{},
},
{
name: "agent and scope filters",
cli: "--agent claude-code --scope user",
wantOpts: ListOptions{
Agent: "claude-code",
Scope: "user",
ScopeChanged: true,
},
},
{
name: "custom dir",
cli: "--dir ./skills",
wantOpts: ListOptions{
Dir: "./skills",
},
},
{
name: "json fields",
cli: "--json skillName,sourceURL,scope,version,pinned,path",
wantJSON: true,
},
{
name: "too many args",
cli: "extra",
wantErr: "unknown command",
},
{
name: "invalid agent",
cli: "--agent unknown",
wantErr: "invalid argument",
},
{
name: "invalid scope",
cli: "--scope org",
wantErr: "invalid argument",
},
{
name: "dir and agent are mutually exclusive",
cli: "--dir ./skills --agent claude-code",
wantErr: "--dir and --agent cannot be used together",
},
{
name: "dir and scope are mutually exclusive",
cli: "--dir ./skills --scope user",
wantErr: "--dir and --scope cannot be used together",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ios, _, _, _ := iostreams.Test()
f := &cmdutil.Factory{IOStreams: ios, GitClient: &git.Client{}}
var gotOpts *ListOptions
cmd := NewCmdList(f, &telemetry.NoOpService{}, func(opts *ListOptions) error {
gotOpts = opts
return nil
})
args, err := shlex.Split(tt.cli)
require.NoError(t, err)
cmd.SetArgs(args)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err = cmd.Execute()
if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
return
}
require.NoError(t, err)
require.NotNil(t, gotOpts)
assert.Equal(t, tt.wantOpts.Agent, gotOpts.Agent)
assert.Equal(t, tt.wantOpts.Scope, gotOpts.Scope)
assert.Equal(t, tt.wantOpts.ScopeChanged, gotOpts.ScopeChanged)
assert.Equal(t, tt.wantOpts.Dir, gotOpts.Dir)
if tt.wantJSON {
assert.NotNil(t, gotOpts.Exporter)
}
})
}
}
func TestNewCmdList_Metadata(t *testing.T) {
ios, _, _, _ := iostreams.Test()
f := &cmdutil.Factory{IOStreams: ios, GitClient: &git.Client{}}
cmd := NewCmdList(f, &telemetry.NoOpService{}, nil)
assert.Equal(t, "list [flags]", cmd.Use)
assert.NotEmpty(t, cmd.Short)
assert.NotEmpty(t, cmd.Long)
assert.NotEmpty(t, cmd.Example)
assert.Contains(t, cmd.Aliases, "ls")
for _, flag := range []string{"agent", "scope", "dir", "json"} {
assert.NotNil(t, cmd.Flags().Lookup(flag), "missing flag: --%s", flag)
}
}
func TestListRun(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T, repoDir, homeDir string)
opts func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions
wantStdout string
wantJSON string
wantErr string
verify func(t *testing.T, stdout string, spy *telemetry.CommandRecorderSpy)
}{
{
name: "lists project skill for selected shared agent",
setup: func(t *testing.T, repoDir, homeDir string) {
writeSkill(t, repoDir, ".agents/skills/git-commit", remoteSkillFrontmatter("git-commit", "skills/git-commit", "refs/tags/v1.0.0", ""))
},
opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions {
return &ListOptions{
IO: ios,
Telemetry: spy,
GitClient: &git.Client{RepoDir: repoDir},
Agent: "cursor",
Scope: "project",
}
},
wantStdout: "git-commit\tcursor\tproject\tmonalisa/skills-repo\n",
verify: func(t *testing.T, stdout string, spy *telemetry.CommandRecorderSpy) {
require.Len(t, spy.Events, 1)
event := spy.Events[0]
assert.Equal(t, "skill_list", event.Type)
assert.Equal(t, "cursor", event.Dimensions["agent_hosts"])
assert.Equal(t, "project", event.Dimensions["scope"])
assert.Equal(t, int64(1), event.Measures["skill_count"])
},
},
{
name: "lists user skill as json",
setup: func(t *testing.T, repoDir, homeDir string) {
writeSkill(t, homeDir, ".claude/skills/code-review", remoteSkillFrontmatter("code-review", "skills/code-review", "refs/tags/v2.0.0", "v2.0.0"))
},
opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions {
exporter := cmdutil.NewJSONExporter()
exporter.SetFields([]string{"skillName", "hosts", "scope", "sourceURL", "version", "pinned", "path"})
return &ListOptions{
IO: ios,
Telemetry: spy,
GitClient: &git.Client{RepoDir: repoDir},
Exporter: exporter,
Agent: "claude-code",
Scope: "user",
}
},
wantJSON: fmt.Sprintf(`[
{
"skillName": "code-review",
"hosts": ["claude-code"],
"scope": "user",
"sourceURL": "https://github.com/monalisa/skills-repo",
"version": "v2.0.0",
"pinned": true,
"path": %q
}
]`, filepath.Join("HOME", ".claude", "skills", "code-review")),
verify: func(t *testing.T, stdout string, spy *telemetry.CommandRecorderSpy) {
assert.Equal(t, "json", spy.Events[0].Dimensions["format"])
},
},
{
name: "custom directory with local metadata",
setup: func(t *testing.T, repoDir, homeDir string) {
customDir := filepath.Join(repoDir, "custom-skills")
writeSkill(t, customDir, "local-helper", heredoc.Doc(`
---
name: local-helper
metadata:
local-path: /src/local-helper
---
Body
`))
},
opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions {
return &ListOptions{
IO: ios,
Telemetry: spy,
GitClient: &git.Client{RepoDir: repoDir},
Dir: filepath.Join(repoDir, "custom-skills"),
}
},
wantStdout: "local-helper\t-\tcustom\t/src/local-helper\n",
},
{
name: "recovers namespaced skill name from source path",
setup: func(t *testing.T, repoDir, homeDir string) {
writeSkill(t, repoDir, ".agents/skills/xlsx-pro", remoteSkillFrontmatter("xlsx-pro", "skills/bob/xlsx-pro", "refs/heads/main", ""))
},
opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions {
return &ListOptions{
IO: ios,
Telemetry: spy,
GitClient: &git.Client{RepoDir: repoDir},
Agent: "github-copilot",
Scope: "project",
}
},
wantStdout: "bob/xlsx-pro\tgithub-copilot\tproject\tmonalisa/skills-repo\n",
},
{
name: "no installed skills returns no results",
opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions {
return &ListOptions{
IO: ios,
Telemetry: spy,
GitClient: &git.Client{RepoDir: repoDir},
Agent: "github-copilot",
Scope: "project",
}
},
wantErr: "no installed skills found",
},
{
name: "no installed skills with json returns empty array",
opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions {
exporter := cmdutil.NewJSONExporter()
exporter.SetFields([]string{"skillName"})
return &ListOptions{
IO: ios,
Telemetry: spy,
GitClient: &git.Client{RepoDir: repoDir},
Exporter: exporter,
Agent: "github-copilot",
Scope: "project",
}
},
wantJSON: "[]",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repoDir := t.TempDir()
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
if tt.setup != nil {
tt.setup(t, repoDir, homeDir)
}
ios, _, stdout, _ := iostreams.Test()
ios.SetStdoutTTY(false)
spy := &telemetry.CommandRecorderSpy{}
opts := tt.opts(ios, repoDir, homeDir, spy)
err := listRun(opts)
if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
return
}
require.NoError(t, err)
if tt.wantJSON != "" {
expected := tt.wantJSON
expected = string(bytes.ReplaceAll([]byte(expected), []byte(filepath.Join("HOME")), []byte(homeDir)))
assert.JSONEq(t, expected, stdout.String())
} else {
assert.Equal(t, tt.wantStdout, stdout.String())
}
if tt.verify != nil {
tt.verify(t, stdout.String(), spy)
}
})
}
}
func TestRenderTableUsesAgentHeader(t *testing.T) {
ios, _, stdout, _ := iostreams.Test()
ios.SetStdoutTTY(true)
err := renderTable(ios, []listedSkill{{
skillName: "git-commit",
hostIDs: []string{"github-copilot"},
scope: "project",
source: "monalisa/skills-repo",
version: "v1.0.0",
}})
require.NoError(t, err)
assert.Contains(t, stdout.String(), "AGENT")
assert.NotContains(t, stdout.String(), "HOST")
}
func writeSkill(t *testing.T, baseDir, relDir, content string) {
t.Helper()
skillDir := filepath.Join(baseDir, filepath.FromSlash(relDir))
require.NoError(t, os.MkdirAll(skillDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644))
}
func remoteSkillFrontmatter(name, sourcePath, ref, pinned string) string {
pinnedLine := ""
if pinned != "" {
pinnedLine = fmt.Sprintf(" github-pinned: %s\n", pinned)
}
return fmt.Sprintf(heredoc.Doc(`
---
name: %s
metadata:
github-repo: https://github.com/monalisa/skills-repo
github-ref: %s
github-tree-sha: abc123
github-path: %s
%s---
Body
`), name, ref, sourcePath, pinnedLine)
}

View file

@ -4,6 +4,7 @@ import (
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/gh/ghtelemetry"
"github.com/cli/cli/v2/pkg/cmd/skills/install"
skilllist "github.com/cli/cli/v2/pkg/cmd/skills/list"
"github.com/cli/cli/v2/pkg/cmd/skills/preview"
"github.com/cli/cli/v2/pkg/cmd/skills/publish"
"github.com/cli/cli/v2/pkg/cmd/skills/search"
@ -32,6 +33,9 @@ func NewCmdSkills(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder) *co
# Install a skill
$ gh skill install github/awesome-copilot documentation-writer
# List installed skills
$ gh skill list
# Preview a skill before installing
$ gh skill preview github/awesome-copilot documentation-writer
@ -48,6 +52,7 @@ func NewCmdSkills(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder) *co
}
cmd.AddCommand(install.NewCmdInstall(f, telemetry, nil))
cmd.AddCommand(skilllist.NewCmdList(f, telemetry, nil))
cmd.AddCommand(preview.NewCmdPreview(f, telemetry, nil))
cmd.AddCommand(publish.NewCmdPublish(f, nil))
cmd.AddCommand(search.NewCmdSearch(f, telemetry, nil))