From d9fab039abdb38a62bd1aa1082ff17018219ef87 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Mon, 11 May 2026 10:54:57 +0100 Subject: [PATCH] add skill list command --- pkg/cmd/skills/list/list.go | 480 +++++++++++++++++++++++++++++++ pkg/cmd/skills/list/list_test.go | 348 ++++++++++++++++++++++ pkg/cmd/skills/skills.go | 5 + 3 files changed, 833 insertions(+) create mode 100644 pkg/cmd/skills/list/list.go create mode 100644 pkg/cmd/skills/list/list_test.go diff --git a/pkg/cmd/skills/list/list.go b/pkg/cmd/skills/list/list.go new file mode 100644 index 000000000..8e687b2e8 --- /dev/null +++ b/pkg/cmd/skills/list/list.go @@ -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] = ®istry.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), + }, + }) +} diff --git a/pkg/cmd/skills/list/list_test.go b/pkg/cmd/skills/list/list_test.go new file mode 100644 index 000000000..4b8b397e8 --- /dev/null +++ b/pkg/cmd/skills/list/list_test.go @@ -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) +} diff --git a/pkg/cmd/skills/skills.go b/pkg/cmd/skills/skills.go index 05a87c386..1399d049b 100644 --- a/pkg/cmd/skills/skills.go +++ b/pkg/cmd/skills/skills.go @@ -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))